Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3f05df2
feat(desktop): add in-channel unread pill and New divider
Jun 12, 2026
f431fc1
fix(desktop): guard unread pill dismiss until pill has been shown
Jun 12, 2026
7e6f5f1
fix(desktop): clear open frontier on channel leave to prevent phantom…
Jun 12, 2026
2dca3a0
test(desktop): add E2E screenshot spec for unread pill and divider
Jun 12, 2026
585f372
fix(desktop): render unread pill on receive by dropping redundant mar…
Jun 12, 2026
70e0012
test(desktop): cover unread read-marker fold and receive-then-reopen …
Jun 12, 2026
c44ec93
fix(desktop): anchor unread pill below channel header so it renders o…
Jun 12, 2026
1ba4bf5
fix(desktop): measure day-divider balance to the unread divider when …
Jun 12, 2026
ffd2541
fix(desktop): suppress unread divider when first unread is the first …
Jun 12, 2026
5ec6ebb
feat(desktop): add thread unread indicators
Jun 12, 2026
21ba067
fix(desktop): gate thread read-state and add eviction strategy
Jun 12, 2026
9f33f5f
style(desktop): fix biome formatting and lint suppression
Jun 12, 2026
6f2560f
test(desktop): add thread unread indicator E2E screenshot spec
Jun 13, 2026
112e170
fix(desktop): adjust thread panel height threshold for UnreadDivider
Jun 13, 2026
54d7372
test(desktop): screenshot a deeply nested thread with unread replies
Jun 13, 2026
ddb2234
test(desktop): tie deep-nested-unread assertion to rendered depth
Jun 13, 2026
6703b50
feat(desktop): propagate subtree unread counts in thread stats lib
Jun 13, 2026
0717f84
feat(desktop): mark thread branches read on open and expand
Jun 13, 2026
ad92393
feat(desktop): render subtree unread badge on in-panel thread rows
Jun 13, 2026
12c0219
fix(desktop): clear in-panel badges beneath an expanded thread branch
Jun 13, 2026
5c88dea
test(desktop): screenshot in-panel subtree unread badge and its clear…
Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export default defineConfig({
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity-screenshots.spec.ts",
"**/unread-pill-screenshots.spec.ts",
"**/thread-unread-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
44 changes: 27 additions & 17 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,24 +376,32 @@ export function AppShell() {
mutedRootIds,
muteThread,
unmuteThread,
} = useUnreadChannels(
sidebarChannels,
activeChannel,
// Wait for ChannelScreen to report the latest loaded message before
// advancing unread state for the active channel.
null,
{
pubkey: identityQuery.data?.pubkey,
relayClient,
currentPubkey: identityQuery.data?.pubkey,
mutedChannelIds,
notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
followedRootIds,
} = useUnreadChannels(sidebarChannels, activeChannel, {
pubkey: identityQuery.data?.pubkey,
relayClient,
currentPubkey: identityQuery.data?.pubkey,
mutedChannelIds,
notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
followedRootIds,
});

const getThreadReadAt = React.useCallback(
(rootId: string) => getChannelReadAt(`thread:${rootId}`),
[getChannelReadAt],
);

const markThreadRead = React.useCallback(
(rootId: string, timestamp: number) => {
markChannelRead(
`thread:${rootId}`,
new Date(timestamp * 1_000).toISOString(),
);
},
[markChannelRead],
);

// Badge count is computed here (rather than inside useHomeFeedNotifications)
Expand Down Expand Up @@ -740,6 +748,8 @@ export function AppShell() {
setIsChannelManagementOpen(true);
},
getChannelReadAt,
getThreadReadAt,
markThreadRead,
readStateVersion,
followThread: handleFollowThread,
unfollowThread: handleUnfollowThread,
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type AppShellContextValue = {
// when unknown. Backed by the single AppShell-mounted ReadStateManager so
// every surface (sidebar, home, badges) projects from the same source.
getChannelReadAt: (channelId: string) => number | null;
// Thread read frontier as unix-seconds timestamp, or null when never read.
// Uses `thread:<rootId>` context keys in the same ReadStateManager.
getThreadReadAt: (rootId: string) => number | null;
// Advance the thread read frontier to the given unix-seconds timestamp.
markThreadRead: (rootId: string, timestamp: number) => void;
// Bump-counter that invalidates whenever the read marker changes. Include
// in memo deps that consume getChannelReadAt.
readStateVersion: number;
Expand All @@ -32,6 +37,8 @@ const AppShellContext = React.createContext<AppShellContextValue>({
openCreateChannel: () => {},
openChannelManagement: () => {},
getChannelReadAt: () => null,
getThreadReadAt: () => null,
markThreadRead: () => {},
readStateVersion: 0,
followThread: () => {},
unfollowThread: () => {},
Expand Down
188 changes: 188 additions & 0 deletions desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import test from "node:test";

import { computeThreadUnreadMarker } from "../../messages/lib/unreadMarker.ts";
import {
directRepliesMaxCreatedAt,
subtreeMaxCreatedAt,
} from "./subtreeCreatedAt.ts";

// Tree: w(100)
// ├── deep1(400) ── deep2(500)
// └── sib(300)
// `deep1` is the deep branch (subtree-max 500); `sib` is a shallower sibling
// whose only reply (300) is chronologically older than the deep tail.
function fixture() {
const directReplyIdsByParentId = new Map([
["w", ["deep1", "sib"]],
["deep1", ["deep2"]],
]);
const createdAtByMessageId = new Map([
["w", 100],
["deep1", 400],
["deep2", 500],
["sib", 300],
]);
const replies = [
{ id: "sib", createdAt: 300 },
{ id: "deep1", createdAt: 400 },
{ id: "deep2", createdAt: 500 },
];
return { directReplyIdsByParentId, createdAtByMessageId, replies };
}

test("subtreeMaxCreatedAt_branchWithDescendants_returnsDeepestCreatedAt", () => {
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();

const result = subtreeMaxCreatedAt(
"deep1",
directReplyIdsByParentId,
createdAtByMessageId,
);

// Includes the descendant deep2(500), not just deep1's own 400.
assert.equal(result, 500);
});

test("subtreeMaxCreatedAt_leafBranch_returnsOwnCreatedAt", () => {
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();

const result = subtreeMaxCreatedAt(
"sib",
directReplyIdsByParentId,
createdAtByMessageId,
);

assert.equal(result, 300);
});

test("subtreeMaxCreatedAt_absentMessage_returnsNull", () => {
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();

const result = subtreeMaxCreatedAt(
"ghost",
directReplyIdsByParentId,
createdAtByMessageId,
);

// Null signals the caller to skip the read-state write.
assert.equal(result, null);
});

// Invariant 3: expanding the deep branch advances the single monotonic frontier
// to the branch subtree-max (500), which consumes the chronologically-older
// unexpanded sibling (300) too. This is the accepted single-frontier semantic.
test("expandDeepBranch_advancesFrontierToSubtreeMax_consumesOlderSibling", () => {
const { directReplyIdsByParentId, createdAtByMessageId, replies } = fixture();

const frontier = subtreeMaxCreatedAt(
"deep1",
directReplyIdsByParentId,
createdAtByMessageId,
);
const marker = computeThreadUnreadMarker(replies, frontier);

assert.equal(frontier, 500);
// Everything at or below 500 is read — including sib(300), never expanded.
assert.equal(marker.firstUnreadReplyId, null);
assert.equal(marker.unreadCount, 0);
});

// directRepliesMaxCreatedAt covers the head and its DIRECT replies only — it
// must NOT descend into deeper branches. Here w's direct replies are deep1(400)
// and sib(300); deep2(500) is a grandchild and must be excluded.
test("directRepliesMaxCreatedAt_excludesDeeperDescendants", () => {
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();

const result = directRepliesMaxCreatedAt(
"w",
directReplyIdsByParentId,
createdAtByMessageId,
);

// max(w=100, deep1=400, sib=300) = 400 — deep2(500) is NOT counted.
assert.equal(result, 400);
});

test("directRepliesMaxCreatedAt_noReplies_returnsOwnCreatedAt", () => {
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();

const result = directRepliesMaxCreatedAt(
"sib",
directReplyIdsByParentId,
createdAtByMessageId,
);

assert.equal(result, 300);
});

test("directRepliesMaxCreatedAt_absentMessage_returnsNull", () => {
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();

const result = directRepliesMaxCreatedAt(
"ghost",
directReplyIdsByParentId,
createdAtByMessageId,
);

assert.equal(result, null);
});

// Invariant 2: opening a thread advances the frontier to the visible direct
// replies' max, consuming them (their channel badge clears), while a deeper
// collapsed branch stays unread until expanded. The channel badge counts the
// head's DIRECT replies, so we assert against those.
test("openThread_frontierAtDirectRepliesMax_consumesVisible_keepsDeeperUnread", () => {
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();

const openFrontier = directRepliesMaxCreatedAt(
"w",
directReplyIdsByParentId,
createdAtByMessageId,
);

// The channel badge is computed over the head's direct replies.
const directReplies = [
{ id: "deep1", createdAt: 400 },
{ id: "sib", createdAt: 300 },
];
const channelBadge = computeThreadUnreadMarker(directReplies, openFrontier);

assert.equal(openFrontier, 400);
// Both visible direct replies are at/below 400 → channel badge clears.
assert.equal(channelBadge.unreadCount, 0);

// But the deeper collapsed branch deep2(500) is still unread until expanded.
const deepBranch = [{ id: "deep2", createdAt: 500 }];
const deepUnread = computeThreadUnreadMarker(deepBranch, openFrontier);
assert.equal(deepUnread.unreadCount, 1);
});

// Invariant 1: the session divider is computed from the open-time frontier
// SNAPSHOT, the badge/consume from the LIVE frontier. After expand advances the
// live frontier to the subtree-max (500), the two clocks deliberately diverge:
// the live frontier reports everything consumed, while the divider — read from
// the frozen open-time snapshot (100) — stays pinned on the first unread reply.
// This is what keeps the divider from moving mid-session when you expand.
test("expandAfterOpen_dividerFromSnapshot_holds_whileLiveFrontierConsumes", () => {
const { directReplyIdsByParentId, createdAtByMessageId, replies } = fixture();

const openSnapshot = 100;
const liveFrontierAfterExpand = subtreeMaxCreatedAt(
"deep1",
directReplyIdsByParentId,
createdAtByMessageId,
);

const dividerFromSnapshot = computeThreadUnreadMarker(replies, openSnapshot);
const consumeFromLive = computeThreadUnreadMarker(
replies,
liveFrontierAfterExpand,
);

// Divider stays on the first unread, computed against the frozen snapshot...
assert.equal(dividerFromSnapshot.firstUnreadReplyId, "sib");
assert.equal(dividerFromSnapshot.unreadCount, 3);
// ...even though the live frontier has consumed the whole branch.
assert.equal(consumeFromLive.firstUnreadReplyId, null);
});
54 changes: 54 additions & 0 deletions desktop/src/features/channels/lib/subtreeCreatedAt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Newest `createdAt` across a thread branch: the message itself plus every
* descendant, walked through the direct-children adjacency map. Drilling into a
* branch advances the thread read frontier to this value, so it determines how
* far "expanding consumes unread" reaches. Returns null when the message is
* absent from the timeline so the caller can skip the read-state write.
*/
export function subtreeMaxCreatedAt(
messageId: string,
directReplyIdsByParentId: ReadonlyMap<string, string[]>,
createdAtByMessageId: ReadonlyMap<string, number>,
): number | null {
const ownCreatedAt = createdAtByMessageId.get(messageId);
if (ownCreatedAt === undefined) return null;

let maxCreatedAt = ownCreatedAt;
const pendingIds = [...(directReplyIdsByParentId.get(messageId) ?? [])];
while (pendingIds.length > 0) {
const currentId = pendingIds.pop();
if (!currentId) continue;
const createdAt = createdAtByMessageId.get(currentId);
if (createdAt !== undefined && createdAt > maxCreatedAt) {
maxCreatedAt = createdAt;
}
pendingIds.push(...(directReplyIdsByParentId.get(currentId) ?? []));
}
return maxCreatedAt;
}

/**
* Newest `createdAt` across a thread head and its DIRECT replies only — the
* content visible the instant the panel opens, before any branch is expanded.
* Opening a thread advances the read frontier to this, mirroring channel-open
* parity: you see (and thus consume) the top-level replies on open, while
* deeper collapsed branches stay unread until drilled into. Returns null when
* the head is absent so the caller can skip the read-state write.
*/
export function directRepliesMaxCreatedAt(
messageId: string,
directReplyIdsByParentId: ReadonlyMap<string, string[]>,
createdAtByMessageId: ReadonlyMap<string, number>,
): number | null {
const ownCreatedAt = createdAtByMessageId.get(messageId);
if (ownCreatedAt === undefined) return null;

let maxCreatedAt = ownCreatedAt;
for (const replyId of directReplyIdsByParentId.get(messageId) ?? []) {
const createdAt = createdAtByMessageId.get(replyId);
if (createdAt !== undefined && createdAt > maxCreatedAt) {
maxCreatedAt = createdAt;
}
}
return maxCreatedAt;
}
Loading