diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index d9b0ee127..42a8493ee 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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"], diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 81cd477f5..e45f9feef 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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) @@ -740,6 +748,8 @@ export function AppShell() { setIsChannelManagementOpen(true); }, getChannelReadAt, + getThreadReadAt, + markThreadRead, readStateVersion, followThread: handleFollowThread, unfollowThread: handleUnfollowThread, diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 730b9fb34..f655399ca 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -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:` 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; @@ -32,6 +37,8 @@ const AppShellContext = React.createContext({ openCreateChannel: () => {}, openChannelManagement: () => {}, getChannelReadAt: () => null, + getThreadReadAt: () => null, + markThreadRead: () => {}, readStateVersion: 0, followThread: () => {}, unfollowThread: () => {}, diff --git a/desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs b/desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs new file mode 100644 index 000000000..1239003fe --- /dev/null +++ b/desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs @@ -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); +}); diff --git a/desktop/src/features/channels/lib/subtreeCreatedAt.ts b/desktop/src/features/channels/lib/subtreeCreatedAt.ts new file mode 100644 index 000000000..ec5f3e7f3 --- /dev/null +++ b/desktop/src/features/channels/lib/subtreeCreatedAt.ts @@ -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, + createdAtByMessageId: ReadonlyMap, +): 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, + createdAtByMessageId: ReadonlyMap, +): 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; +} diff --git a/desktop/src/features/channels/lib/threadReplyUnreadCounts.test.mjs b/desktop/src/features/channels/lib/threadReplyUnreadCounts.test.mjs new file mode 100644 index 000000000..f395acc3e --- /dev/null +++ b/desktop/src/features/channels/lib/threadReplyUnreadCounts.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { computeThreadReplyUnreadCounts } from "./threadReplyUnreadCounts.ts"; + +// Open thread "root": +// root(100) +// ├── a(200) ── a1(400) +// └── b(300) ── b1(500) ── b2(600) +// Sibling thread "other" lives outside root's subtree. +function fixture() { + return [ + { id: "root", createdAt: 100, parentId: null }, + { id: "a", createdAt: 200, parentId: "root" }, + { id: "b", createdAt: 300, parentId: "root" }, + { id: "a1", createdAt: 400, parentId: "a" }, + { id: "b1", createdAt: 500, parentId: "b" }, + { id: "b2", createdAt: 600, parentId: "b1" }, + { id: "other", createdAt: 700, parentId: null }, + { id: "other1", createdAt: 800, parentId: "other" }, + ]; +} + +const ROOT_SUBTREE = ["a", "b", "a1", "b1", "b2"]; + +test("computeThreadReplyUnreadCounts_collapsedBranch_countsUnreadDescendants", () => { + // Frontier 350: a1(400), b1(500), b2(600) are unread. + const counts = computeThreadReplyUnreadCounts({ + timelineMessages: fixture(), + subtreeReplyIds: ROOT_SUBTREE, + visibleReplyIds: ["a", "b"], + expandedReplyIds: new Set(), + expandedSubtreeReplyIds: new Set(), + frontierSeconds: 350, + }); + assert.equal(counts.get("a"), 1); // a1 + assert.equal(counts.get("b"), 2); // b1, b2 +}); + +test("computeThreadReplyUnreadCounts_expandedBranch_omitsBadge", () => { + const counts = computeThreadReplyUnreadCounts({ + timelineMessages: fixture(), + subtreeReplyIds: ROOT_SUBTREE, + visibleReplyIds: ["a", "b"], + expandedReplyIds: new Set(["b"]), + expandedSubtreeReplyIds: new Set(["b1", "b2"]), + frontierSeconds: 350, + }); + assert.equal(counts.get("a"), 1); + assert.equal(counts.has("b"), false); +}); + +test("computeThreadReplyUnreadCounts_expandedBranch_revealedChildNoStaleBadge", () => { + // Expand b: mark-read-on-expand reads b's whole subtree, and the panel now + // reveals collapsed child b1 (descendant b2 still unread vs the open-time + // frontier). b1 must carry NO badge — the expanded subtree is excluded. + const counts = computeThreadReplyUnreadCounts({ + timelineMessages: fixture(), + subtreeReplyIds: ROOT_SUBTREE, + visibleReplyIds: ["a", "b", "b1"], + expandedReplyIds: new Set(["b"]), + expandedSubtreeReplyIds: new Set(["b1", "b2"]), + frontierSeconds: 350, + }); + assert.equal(counts.get("a"), 1); + assert.equal(counts.has("b"), false); + assert.equal(counts.has("b1"), false); +}); + +test("computeThreadReplyUnreadCounts_descendantsButNoneUnread_noBadge", () => { + // Frontier 1000: nothing is newer, so no unread descendants anywhere. + const counts = computeThreadReplyUnreadCounts({ + timelineMessages: fixture(), + subtreeReplyIds: ROOT_SUBTREE, + visibleReplyIds: ["a", "b"], + expandedReplyIds: new Set(), + expandedSubtreeReplyIds: new Set(), + frontierSeconds: 1000, + }); + assert.equal(counts.size, 0); +}); + +test("computeThreadReplyUnreadCounts_nullFrontier_allDescendantsUnread", () => { + const counts = computeThreadReplyUnreadCounts({ + timelineMessages: fixture(), + subtreeReplyIds: ROOT_SUBTREE, + visibleReplyIds: ["a", "b"], + expandedReplyIds: new Set(), + expandedSubtreeReplyIds: new Set(), + frontierSeconds: null, + }); + assert.equal(counts.get("a"), 1); // a1 + assert.equal(counts.get("b"), 2); // b1, b2 +}); + +test("computeThreadReplyUnreadCounts_otherThreadReply_notCounted", () => { + // other1(800) is unread by frontier but outside root's subtree — its + // ancestor "other" is not a visible row here and must never be keyed. + const counts = computeThreadReplyUnreadCounts({ + timelineMessages: fixture(), + subtreeReplyIds: ROOT_SUBTREE, + visibleReplyIds: ["a", "b", "other"], + expandedReplyIds: new Set(), + expandedSubtreeReplyIds: new Set(), + frontierSeconds: 350, + }); + assert.equal(counts.has("other"), false); +}); + +test("computeThreadReplyUnreadCounts_onlyVisibleRowsKeyed", () => { + // b is collapsed and unread, but not in the visible set this render. + const counts = computeThreadReplyUnreadCounts({ + timelineMessages: fixture(), + subtreeReplyIds: ROOT_SUBTREE, + visibleReplyIds: ["a"], + expandedReplyIds: new Set(), + expandedSubtreeReplyIds: new Set(), + frontierSeconds: 350, + }); + assert.equal(counts.get("a"), 1); + assert.equal(counts.has("b"), false); +}); diff --git a/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts b/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts new file mode 100644 index 000000000..c7337f21b --- /dev/null +++ b/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts @@ -0,0 +1,67 @@ +import { buildDescendantStatsByMessageId } from "@/features/messages/lib/threadPanel"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Per-row subtree unread counts for the in-panel thread summary rows. A + * collapsed branch's badge counts unread replies anywhere beneath it; the + * count is omitted for expanded branches (suppress-on-expand happens here, + * upstream of the panel, so the panel needs no gate) and for rows with zero + * unread descendants (no "0" badge). + * + * Unread is measured against the open-time frontier snapshot — the same + * boundary the in-thread divider uses — so the mark-read-on-open advance does + * not zero the badges the instant the panel opens. A null frontier (thread + * never read) treats every subtree reply as unread. + * + * @param subtreeReplyIds Descendant reply ids of the open thread head. Scoping + * the unread set to this subtree keeps one thread's frontier from marking + * replies that belong to a different thread. + * @param visibleReplyIds Ids of the rows actually rendered in the panel; only + * these are keyed, keeping the map consistent with row presence. + * @param expandedSubtreeReplyIds Reply ids beneath any expanded row. Expanding + * a branch persistently marks its whole subtree read (mark-read-on-expand), + * so those replies are dropped from the unread set — otherwise a revealed + * child would carry a stale badge for a reply the same gesture just read. + */ +export function computeThreadReplyUnreadCounts(params: { + timelineMessages: TimelineMessage[]; + subtreeReplyIds: Iterable; + visibleReplyIds: Iterable; + expandedReplyIds: ReadonlySet; + expandedSubtreeReplyIds: ReadonlySet; + frontierSeconds: number | null; +}): Map { + const { + timelineMessages, + subtreeReplyIds, + visibleReplyIds, + expandedReplyIds, + expandedSubtreeReplyIds, + frontierSeconds, + } = params; + + const subtree = new Set(subtreeReplyIds); + const unreadReplyIds = new Set( + timelineMessages + .filter( + (message) => + subtree.has(message.id) && + !expandedSubtreeReplyIds.has(message.id) && + (frontierSeconds === null || message.createdAt > frontierSeconds), + ) + .map((message) => message.id), + ); + + const stats = buildDescendantStatsByMessageId( + timelineMessages, + unreadReplyIds, + ); + + const counts = new Map(); + for (const replyId of visibleReplyIds) { + if (expandedReplyIds.has(replyId)) continue; + const unread = stats.get(replyId)?.unreadDescendantCount ?? 0; + if (unread > 0) counts.set(replyId, unread); + } + return counts; +} diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index c777e3694..8b69682f4 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -536,6 +536,27 @@ export class ReadStateManager { } contexts[ctx] = ts; } + + // Evict oldest thread:* entries when approaching the MAX_CONTEXTS cap + // (10,000). Channel keys are never evicted — only thread read-state keys + // are eligible. This prevents silent blob rejection by isValidBlob(). + const EVICTION_THRESHOLD = 8_000; + const contextCount = Object.keys(contexts).length; + if (contextCount > EVICTION_THRESHOLD) { + const threadEntries: [string, number][] = []; + for (const [key, ts] of Object.entries(contexts)) { + if (key.startsWith("thread:")) { + threadEntries.push([key, ts]); + } + } + // Sort oldest-first (lowest timestamp = oldest) + threadEntries.sort((a, b) => a[1] - b[1]); + const toEvict = contextCount - EVICTION_THRESHOLD; + for (let i = 0; i < Math.min(toEvict, threadEntries.length); i++) { + delete contexts[threadEntries[i][0]]; + } + } + return contexts; } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index df53071ae..6ba109135 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -67,6 +67,10 @@ type ChannelPaneProps = { isSending: boolean; isTimelineLoading: boolean; messages: TimelineMessage[]; + /** Event id of the oldest unread top-level message at channel open, or null. */ + firstUnreadMessageId?: string | null; + /** Count of unread top-level messages at channel open. */ + unreadCount?: number; canResetThreadPanelWidth: boolean; onCancelEdit?: () => void; onCancelThreadReply: () => void; @@ -127,6 +131,12 @@ type ChannelPaneProps = { threadTypingPubkeys: string[]; threadReplyTargetMessage: TimelineMessage | null; threadScrollTargetId: string | null; + /** Per-thread unread counts keyed by thread root id. */ + threadUnreadCounts?: ReadonlyMap; + /** Subtree unread counts for in-panel summary rows, keyed by reply id. */ + threadReplyUnreadCounts?: ReadonlyMap; + /** Event id of the first unread reply in the open thread panel. */ + threadFirstUnreadReplyId?: string | null; targetMessageId: string | null; typingPubkeys: string[]; isFollowingThread?: boolean; @@ -207,6 +217,8 @@ export const ChannelPane = React.memo(function ChannelPane({ isSending, isTimelineLoading, messages, + firstUnreadMessageId = null, + unreadCount = 0, canResetThreadPanelWidth, onCancelEdit, onCancelThreadReply, @@ -250,6 +262,9 @@ export const ChannelPane = React.memo(function ChannelPane({ threadScrollTargetId, threadTypingPubkeys, threadReplyTargetMessage, + threadUnreadCounts, + threadReplyUnreadCounts, + threadFirstUnreadReplyId, typingPubkeys, }: ChannelPaneProps) { const timelineScrollRef = React.useRef(null); @@ -675,6 +690,8 @@ export const ChannelPane = React.memo(function ChannelPane({ } isLoading={isTimelineLoading} messages={visibleMessages} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} onDelete={onDelete} onEdit={onEdit} onMarkUnread={onMarkUnread} @@ -691,6 +708,7 @@ export const ChannelPane = React.memo(function ChannelPane({ searchMatchingMessageIds={channelFind.matchingMessageIds} searchQuery={channelFind.query} targetMessageId={targetMessageId} + threadUnreadCounts={threadUnreadCounts} /> {isNonMemberView ? (
()); + if (activeChannelId && !openFrontierRef.current.has(activeChannelId)) { + openFrontierRef.current.set( + activeChannelId, + getChannelReadAt(activeChannelId), + ); + } + const openFrontierSeconds = activeChannelId + ? (openFrontierRef.current.get(activeChannelId) ?? null) + : null; + // Channels the user manually marked unread this session. A deliberate + // mark-unread has no meaningful "new" boundary inside the timeline — the + // open-time snapshot already covers every message — so the pill and divider + // would otherwise render nothing while the sidebar dot says unread. Suppress + // the marker for such channels to avoid that visible contradiction. The flag + // is cleared on re-open (a fresh snapshot is recomputed for the channel). + const forcedUnreadRef = React.useRef(new Set()); + const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0); + const isActiveChannelForcedUnread = + !!activeChannelId && forcedUnreadRef.current.has(activeChannelId); + // Drop the forced-unread flag when the user leaves a channel, so reopening + // it recomputes a normal marker rather than staying suppressed forever. + React.useEffect(() => { + const channelId = activeChannelId; + if (!channelId) return; + return () => { + forcedUnreadRef.current.delete(channelId); + }; + }, [activeChannelId]); + // Clear the open-time frontier on channel leave so re-visiting captures a + // fresh read position. Without this, switching away and back would reuse the + // stale frontier from the first open, producing a phantom "New" divider over + // already-read messages. + React.useEffect(() => { + const channelId = activeChannelId; + if (!channelId) return; + return () => { + openFrontierRef.current.delete(channelId); + }; + }, [activeChannelId]); React.useEffect(() => { if (!activeChannelId || activeChannel?.isMember === false) { return; @@ -289,6 +349,19 @@ export function ChannelScreen({ channelId: activeChannelId, messages: timelineMessages, }); + // Oldest-unread top-level message + count, derived from the open-time + // frontier snapshot above. Drives the "N new messages" pill and the "New" + // divider; both stay put even after the open marks the channel read because + // openFrontierSeconds is keyed per channel, not on the live marker. + const { firstUnreadMessageId, unreadCount } = React.useMemo( + () => + computeChannelUnreadMarker( + timelineMessages, + openFrontierSeconds, + isActiveChannelForcedUnread, + ), + [isActiveChannelForcedUnread, openFrontierSeconds, timelineMessages], + ); const directReplyIdsByParentId = React.useMemo(() => { const map = new Map(); for (const message of timelineMessages) { @@ -317,6 +390,26 @@ export function ChannelScreen({ }, [directReplyIdsByParentId], ); + const createdAtByMessageId = React.useMemo(() => { + const map = new Map(); + for (const message of timelineMessages) { + map.set(message.id, message.createdAt); + } + return map; + }, [timelineMessages]); + // Newest createdAt across an expanded branch (the message itself plus every + // descendant). Drilling into a branch advances the thread frontier to this, + // consuming everything chronologically up to the deepest reply read. Returns + // null when the message is absent so the caller skips the read-state write. + const getSubtreeMaxCreatedAt = React.useCallback( + (messageId: string) => + subtreeMaxCreatedAt( + messageId, + directReplyIdsByParentId, + createdAtByMessageId, + ), + [createdAtByMessageId, directReplyIdsByParentId], + ); const threadPanelData = React.useMemo( () => buildThreadPanelData( @@ -335,6 +428,124 @@ export function ChannelScreen({ const openThreadHeadMessage = threadPanelData.threadHead; const threadMessages = threadPanelData.visibleReplies; const threadReplyTargetMessage = threadPanelData.replyTargetMessage; + + // --- Thread unread state --- + // Capture the thread read frontier on open (same pattern as channel frontier). + // Keyed per thread root so switching threads captures a fresh frontier. + const threadOpenFrontierRef = React.useRef(new Map()); + if ( + openThreadHeadId && + !threadOpenFrontierRef.current.has(openThreadHeadId) + ) { + threadOpenFrontierRef.current.set( + openThreadHeadId, + getThreadReadAt(openThreadHeadId), + ); + } + const threadOpenFrontierSeconds = openThreadHeadId + ? (threadOpenFrontierRef.current.get(openThreadHeadId) ?? null) + : null; + // Clear the thread frontier when the thread closes so re-opening captures fresh. + React.useEffect(() => { + const rootId = openThreadHeadId; + if (!rootId) return; + return () => { + threadOpenFrontierRef.current.delete(rootId); + }; + }, [openThreadHeadId]); + // Mark thread read when the panel opens, advancing the frontier to the max + // createdAt over the head and its DIRECT replies — the content visible + // without expanding anything. This mirrors channel-open parity: opening + // consumes what you can see, clearing the channel-level badge for unread that + // lived in the visible direct replies, while deeper collapsed branches stay + // unread until drilled into (expand advances the frontier further from here). + // Only persist read state for threads the user has notification interest in + // (participated, authored, or followed) to avoid bloating the context blob. + React.useEffect(() => { + if (!openThreadHeadId) return; + if (!isNotifiedForCurrentThread) return; + const openReadCeiling = directRepliesMaxCreatedAt( + openThreadHeadId, + directReplyIdsByParentId, + createdAtByMessageId, + ); + if (openReadCeiling === null) return; + markThreadRead(openThreadHeadId, openReadCeiling); + }, [ + openThreadHeadId, + directReplyIdsByParentId, + createdAtByMessageId, + markThreadRead, + isNotifiedForCurrentThread, + ]); + // Compute the in-thread "New" divider position from the open-time frontier. + const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo(() => { + if (!openThreadHeadId || threadMessages.length === 0) { + return { firstUnreadReplyId: null, unreadCount: 0 }; + } + const replies = threadMessages.map((entry) => entry.message); + return computeThreadUnreadMarker(replies, threadOpenFrontierSeconds); + }, [openThreadHeadId, threadMessages, threadOpenFrontierSeconds]); + // Per-row subtree unread counts for the in-panel thread summary rows. Scoped + // to the open thread's subtree and measured against the open-time frontier + // snapshot (so the mark-read-on-open advance doesn't zero the badges); see + // computeThreadReplyUnreadCounts for the suppress-on-expand and no-"0"-badge + // rules. + const threadReplyUnreadCounts = React.useMemo( + () => + openThreadHeadId + ? computeThreadReplyUnreadCounts({ + timelineMessages, + subtreeReplyIds: getReplyDescendantIdsForMessage(openThreadHeadId), + visibleReplyIds: threadMessages.map((entry) => entry.message.id), + expandedReplyIds: expandedThreadReplyIds, + expandedSubtreeReplyIds: new Set( + [...expandedThreadReplyIds].flatMap((id) => + getReplyDescendantIdsForMessage(id), + ), + ), + frontierSeconds: threadOpenFrontierSeconds, + }) + : new Map(), + [ + openThreadHeadId, + threadMessages, + timelineMessages, + threadOpenFrontierSeconds, + expandedThreadReplyIds, + getReplyDescendantIdsForMessage, + ], + ); + // Compute per-thread unread counts for summary rows in the main timeline. + // Only compute for threads the user has notification interest in — this + // aligns the badge display with the read-state write path. + // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getThreadReadAt and isNotifiedForThread without changing their identity + const threadUnreadCounts = React.useMemo(() => { + const counts = new Map(); + for (const message of timelineMessages) { + if (message.parentId) continue; + if (!isNotifiedForThread(message.id)) continue; + // Only compute for messages that have thread replies + const directReplies = timelineMessages.filter( + (m) => m.parentId === message.id, + ); + if (directReplies.length === 0) continue; + const frontier = getThreadReadAt(message.id); + const { unreadCount } = computeThreadUnreadMarker( + directReplies, + frontier, + ); + if (unreadCount > 0) { + counts.set(message.id, unreadCount); + } + } + return counts; + }, [ + timelineMessages, + getThreadReadAt, + isNotifiedForThread, + readStateVersion, + ]); const editTargetMessage = React.useMemo( () => timelineMessages.find((message) => message.id === editTargetId) ?? null, @@ -360,6 +571,8 @@ export function ChannelScreen({ expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, + getSubtreeMaxCreatedAt, + markThreadRead, openThreadHeadId, sendMessageMutation, setExpandedThreadReplyIds, @@ -400,6 +613,10 @@ export function ChannelScreen({ : undefined; const handleMarkUnread = React.useCallback(() => { if (!activeChannelId) return; + // Mirror the deliberate mark-unread locally so the timeline marker is + // suppressed (see forcedUnreadRef above). Re-render so the memo re-runs. + forcedUnreadRef.current.add(activeChannelId); + forceUnreadRender(); markChannelUnread(activeChannelId); }, [activeChannelId, markChannelUnread]); const { @@ -643,6 +860,8 @@ export function ChannelScreen({ profilePanelPubkey={profilePanelPubkey} personaLookup={personaLookup} profiles={messageProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} targetMessageId={mainTimelineTargetMessageId} threadHeadMessage={openThreadHeadMessage} threadMessages={threadMessages} @@ -650,6 +869,9 @@ export function ChannelScreen({ threadTypingPubkeys={threadTypingPubkeys} threadReplyTargetMessage={threadReplyTargetMessage} threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={threadFirstUnreadReplyId} isJoining={joinChannelMutation.isPending} onJoinChannel={joinChannelMutation.mutateAsync} typingPubkeys={humanTypingPubkeys} diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs new file mode 100644 index 000000000..b2fdf5132 --- /dev/null +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts"; +import { resolveChannelReadMarker } from "./useUnreadChannels.ts"; + +function topLevel(id, createdAt) { + return { id, createdAt, author: "a", time: "", body: "", depth: 0 }; +} + +// The headline scenario the fix restores: messages arrive while the channel is +// inactive, the read frontier was captured before them, and on reopen the pill +// and divider must render. The deleted AppShell effect used to fold those +// just-arrived timestamps into the frontier, hiding them; with it gone the +// frontier stays below the new messages. +test("receiveThenReopen_frontierBelowArrivedMessages_showsDivider", () => { + const frontierBeforeReceive = 100; + const arrived = [ + topLevel("seen", 90), + topLevel("new-1", 110), + topLevel("new-2", 120), + ]; + + const marker = computeChannelUnreadMarker(arrived, frontierBeforeReceive); + + assert.equal(marker.firstUnreadMessageId, "new-1"); + assert.equal(marker.unreadCount, 2); +}); + +// Regression guard for the read frontier silently clobbering newly received +// messages: if the marker had advanced to the latest arrival (as the deleted +// effect did), nothing would be unread. +test("receiveThenReopen_frontierAtLatestArrival_clobbersDivider", () => { + const arrived = [topLevel("a", 90), topLevel("b", 110), topLevel("c", 120)]; + + const marker = computeChannelUnreadMarker(arrived, 120); + + assert.equal(marker.firstUnreadMessageId, null); + assert.equal(marker.unreadCount, 0); +}); + +// An explicit caller timeline position must still advance the read marker. This +// is the consumer (ChannelScreen) that marks the active channel read with a +// real position; the fix must not regress it. +test("resolveChannelReadMarker_realReadAt_advancesMarker", () => { + const readAt = "2026-06-12T00:00:00.000Z"; + const expected = Math.floor(Date.parse(readAt) / 1000); + + const result = resolveChannelReadMarker(readAt, undefined); + + assert.equal(result.markAt, expected); + assert.equal(result.clearObserved, false); +}); + +// The Esc-to-mark-read shortcut and sidebar mark-read pass a null/stale caller +// value and rely on the observed-latest fold to mark the channel read. The +// rejected in-function null-guard would have returned markAt === null here, +// silently no-opping those user actions. This proves the fold survives. +test("resolveChannelReadMarker_nullCallerWithObservedLatest_marksViaObserved", () => { + const observedLatest = 200; + + const result = resolveChannelReadMarker(null, observedLatest); + + assert.equal(result.markAt, observedLatest); + assert.equal(result.clearObserved, true); +}); + +// With no caller value and nothing observed there is nothing to mark; the +// marker resolves to null so markChannelRead short-circuits without writing. +test("resolveChannelReadMarker_noCallerNoObserved_returnsNull", () => { + const result = resolveChannelReadMarker(null, undefined); + + assert.equal(result.markAt, null); + assert.equal(result.clearObserved, false); +}); diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 253f70be0..b1d0fcaa2 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -22,6 +22,8 @@ export function useChannelPaneHandlers({ expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, + getSubtreeMaxCreatedAt, + markThreadRead, openThreadHeadId, sendMessageMutation, setExpandedThreadReplyIds, @@ -38,6 +40,8 @@ export function useChannelPaneHandlers({ expandedThreadReplyIds: ReadonlySet; getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; + getSubtreeMaxCreatedAt: (messageId: string) => number | null; + markThreadRead: (rootId: string, timestamp: number) => void; openThreadHeadId: string | null; sendMessageMutation: ReturnType; setExpandedThreadReplyIds: React.Dispatch>>; @@ -180,6 +184,17 @@ export function useChannelPaneHandlers({ return next; }); + // Drilling into a branch consumes its unread, persistently: advance the + // thread frontier to the branch's newest reply. Monotonic Math.max means + // this marks read everything chronologically up to it (channel-open + // parity). The open-time snapshot pins the session divider, so it never + // moves mid-session. + const rootId = openThreadHeadIdRef.current; + const subtreeMaxCreatedAt = getSubtreeMaxCreatedAt(message.id); + if (rootId && subtreeMaxCreatedAt !== null) { + markThreadRead(rootId, subtreeMaxCreatedAt); + } + if (firstReplyId) { setThreadScrollTargetId(firstReplyId); } @@ -187,6 +202,8 @@ export function useChannelPaneHandlers({ [ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, + getSubtreeMaxCreatedAt, + markThreadRead, setExpandedThreadReplyIds, setThreadScrollTargetId, ], diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 7b81fbbb2..2f340942f 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -213,6 +213,30 @@ function toUnixSeconds(isoOrMs: string | null | undefined): number | null { return ms === null ? null : Math.floor(ms / 1_000); } +// Resolve where the read marker should land when a channel is marked read. +// Folds the caller's timeline position together with the newest event this +// client has observed live (`observedLatest`), so an explicit "mark read" still +// covers messages that arrived faster than channel metadata — this fold is +// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open, +// all of which pass a null/stale caller value. `clearObserved` reports whether +// the resulting marker covers the observed timestamp, signalling the caller to +// drop its observed refs so the unread memo sees `latest === undefined` until a +// genuinely newer event arrives. +export function resolveChannelReadMarker( + callerReadAt: string | null | undefined, + observedLatest: number | undefined, +): { markAt: number | null; clearObserved: boolean } { + const callerUnix = toUnixSeconds(callerReadAt); + const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null; + return { + markAt, + clearObserved: + markAt !== null && + observedLatest !== undefined && + observedLatest <= markAt, + }; +} + function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) return false; for (const item of a) { @@ -224,7 +248,6 @@ function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { export function useUnreadChannels( channels: Channel[], activeChannel: Channel | null, - activeReadAt?: string | null, options: UseUnreadChannelsOptions = {}, ) { const { @@ -234,14 +257,8 @@ export function useUnreadChannels( ...liveUpdateOptions } = options; const activeChannelId = activeChannel?.id ?? null; - const activeChannelLastMessageAt = activeChannel?.lastMessageAt ?? null; const normalizedPubkey = pubkey?.toLowerCase() ?? null; - // Let callers pass `null` to intentionally suppress the optimistic - // channel-metadata fallback until a real timeline position is known. - const effectiveActiveReadAt = - activeReadAt === undefined ? activeChannelLastMessageAt : activeReadAt; - const { getEffectiveTimestamp, isReady: isReadStateReady, @@ -341,18 +358,19 @@ export function useUnreadChannels( if (forcedUnreadRef.current.delete(channelId)) { bumpLatestVersion(); } - const callerUnix = toUnixSeconds(readAt); const observedLatest = latestByChannelRef.current.get(channelId); - const unixSeconds = - Math.max(callerUnix ?? 0, observedLatest ?? 0) || null; - if (unixSeconds === null) return; - markContextRead(channelId, unixSeconds); + const { markAt, clearObserved } = resolveChannelReadMarker( + readAt, + observedLatest, + ); + if (markAt === null) return; + markContextRead(channelId, markAt); // Clear observed-latest refs when the read marker covers them so the // unread memo sees `latest === undefined` until a genuinely new event // arrives. Without this, `latest > readAt` resolves to `T > T` (false) // but the channel lingers in the set when advanceContext's monotonic // guard suppresses the readStateVersion bump. - if (observedLatest !== undefined && observedLatest <= unixSeconds) { + if (clearObserved) { latestByChannelRef.current.delete(channelId); latestHighPriorityByChannelRef.current.delete(channelId); bumpLatestVersion(); @@ -371,21 +389,6 @@ export function useUnreadChannels( } }, []); - // Mark the active channel as read when it changes or new messages arrive. - // Honours the caller's contract that a null activeReadAt suppresses - // read-marking until the timeline reports a real position. Manual - // mark-unread state is cleared inside markChannelRead, not here. - React.useEffect(() => { - if (!isReadStateReady) return; - if (!activeChannelId) return; - markChannelRead(activeChannelId, effectiveActiveReadAt); - }, [ - activeChannelId, - effectiveActiveReadAt, - isReadStateReady, - markChannelRead, - ]); - // Feed the in-session "latest external trigger" map from live channel // events. Composes with any caller-supplied onChannelMessage handler. // useLiveChannelUpdates already filters this callback to trigger kinds diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 5129ec871..f8a4ae2c1 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildDescendantStatsByMessageId, buildMainTimelineEntries, buildThreadPanelData, + shouldRenderUnreadDivider, } from "./threadPanel.ts"; function message(overrides) { @@ -100,3 +102,127 @@ test("buildThreadPanelData keeps direct comments unindented", () => { ], ); }); + +test("shouldRenderUnreadDivider_firstUnreadIsFirstRendered_suppressesDivider", () => { + // Fresh/never-read channel: the first message IS the first unread, nothing + // above it to separate from. + assert.equal(shouldRenderUnreadDivider(0, "a", "a"), false); +}); + +test("shouldRenderUnreadDivider_firstUnreadMidTimeline_rendersDivider", () => { + // Real read frontier: read messages above, unread starts at index 2. + assert.equal(shouldRenderUnreadDivider(2, "c", "c"), true); +}); + +test("shouldRenderUnreadDivider_firstUnreadIsFirstOfLaterDay_rendersDivider", () => { + // Multi-day timeline where the first unread is the first message of a later + // day group but not the first rendered entry overall — divider still marks + // the boundary. + assert.equal( + shouldRenderUnreadDivider(5, "later-day-head", "later-day-head"), + true, + ); +}); + +test("shouldRenderUnreadDivider_nonMatchingEntry_noDivider", () => { + assert.equal(shouldRenderUnreadDivider(3, "x", "y"), false); +}); + +test("shouldRenderUnreadDivider_noUnread_noDivider", () => { + assert.equal(shouldRenderUnreadDivider(3, "x", null), false); +}); + +function spine(ids) { + // root -> ids[0] -> ids[1] -> ... each a single-child reply of the previous. + return ids.map((id, index) => + message({ + id, + createdAt: index + 2, + parentId: index === 0 ? "root" : ids[index - 1], + rootId: "root", + depth: index + 1, + }), + ); +} + +function unreadCounts(messages, unreadReplyIds) { + const stats = buildDescendantStatsByMessageId(messages, unreadReplyIds); + return Object.fromEntries( + [...stats].map(([id, stat]) => [id, stat.unreadDescendantCount]), + ); +} + +test("buildDescendantStatsByMessageId_deepUnreadUnderReadParent_bubblesToEveryAncestor", () => { + // root -> r1 -> r2 -> r3 -> r4, only the deepest reply (r4) is unread. + // The count must surface on every ancestor on the spine, not just r4's + // parent — this is the "deep unread under read parents" bug. + const root = message({ id: "root", createdAt: 1 }); + const messages = [root, ...spine(["r1", "r2", "r3", "r4"])]; + + assert.deepEqual(unreadCounts(messages, new Set(["r4"])), { + root: 1, + r1: 1, + r2: 1, + r3: 1, + r4: 0, + }); +}); + +test("buildDescendantStatsByMessageId_noUnreadReplies_allCountsZero", () => { + const root = message({ id: "root", createdAt: 1 }); + const messages = [root, ...spine(["r1", "r2"])]; + + assert.deepEqual(unreadCounts(messages, new Set()), { + root: 0, + r1: 0, + r2: 0, + }); +}); + +test("buildDescendantStatsByMessageId_siblingBranches_countedIndependently", () => { + // root has two independent branches: a (a1, unread) and b (b1, read). + // The unread must attribute to root + a1's chain, never to the b branch. + const root = message({ id: "root", createdAt: 1 }); + const a1 = message({ + id: "a1", + createdAt: 2, + parentId: "root", + rootId: "root", + depth: 1, + }); + const a2 = message({ + id: "a2", + createdAt: 3, + parentId: "a1", + rootId: "root", + depth: 2, + }); + const b1 = message({ + id: "b1", + createdAt: 4, + parentId: "root", + rootId: "root", + depth: 1, + }); + + assert.deepEqual(unreadCounts([root, a1, a2, b1], new Set(["a2"])), { + root: 1, + a1: 1, + a2: 0, + b1: 0, + }); +}); + +test("buildDescendantStatsByMessageId_multipleUnreadOnSpine_accumulatesOnAncestors", () => { + // root -> r1 -> r2 -> r3, with r2 and r3 both unread. Each ancestor counts + // every unread descendant below it, so root sees 2 and r2 sees 1. + const root = message({ id: "root", createdAt: 1 }); + const messages = [root, ...spine(["r1", "r2", "r3"])]; + + assert.deepEqual(unreadCounts(messages, new Set(["r2", "r3"])), { + root: 2, + r1: 2, + r2: 1, + r3: 0, + }); +}); diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 03baa1dd5..87cf43abe 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -26,8 +26,9 @@ export type MainTimelineEntry = { summary: TimelineThreadSummary | null; }; -type ThreadDescendantStats = { +export type ThreadDescendantStats = { descendantCount: number; + unreadDescendantCount: number; lastReplyAt: number | null; recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[]; }; @@ -67,8 +68,9 @@ function buildDirectChildrenByParentId(messages: TimelineMessage[]) { return childrenByParentId; } -function buildDescendantStatsByMessageId( +export function buildDescendantStatsByMessageId( messages: TimelineMessage[], + unreadReplyIds: ReadonlySet, ): Map { const messageById = new Map(messages.map((message) => [message.id, message])); const descendantStatsByMessageId = new Map( @@ -76,6 +78,7 @@ function buildDescendantStatsByMessageId( message.id, { descendantCount: 0, + unreadDescendantCount: 0, lastReplyAt: null, recentParticipantsNewestFirst: [], }, @@ -104,6 +107,7 @@ function buildDescendantStatsByMessageId( let ancestorId = message.parentId ?? null; let hops = 0; const maxHops = messages.length + 1; + const isUnread = unreadReplyIds.has(message.id); while (ancestorId && hops < maxHops) { const ancestorStats = descendantStatsByMessageId.get(ancestorId); @@ -112,6 +116,9 @@ function buildDescendantStatsByMessageId( } ancestorStats.descendantCount += 1; + if (isUnread) { + ancestorStats.unreadDescendantCount += 1; + } ancestorStats.lastReplyAt = Math.max( ancestorStats.lastReplyAt ?? 0, message.createdAt, @@ -220,8 +227,12 @@ function buildVisibleThreadReplies(params: { export function buildMainTimelineEntries( messages: TimelineMessage[], + unreadReplyIds: ReadonlySet = new Set(), ): MainTimelineEntry[] { - const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages); + const descendantStatsByMessageId = buildDescendantStatsByMessageId( + messages, + unreadReplyIds, + ); return messages .filter( @@ -239,11 +250,27 @@ export function buildMainTimelineEntries( }); } +/** + * Whether the unread "New" divider should render above the entry at `index`. + * The divider marks a read/unread boundary, so it only makes sense when there + * is a rendered message above the first unread. When the first unread is the + * first rendered top-level entry (index 0) — the fresh/never-read channel case + * — there is nothing above it to separate from, so the divider is suppressed. + */ +export function shouldRenderUnreadDivider( + index: number, + messageId: string, + firstUnreadMessageId: string | null, +): boolean { + return index > 0 && messageId === firstUnreadMessageId; +} + export function buildThreadPanelData( messages: TimelineMessage[], openThreadHeadId: string | null, threadReplyTargetId: string | null, expandedReplyIds: ReadonlySet, + unreadReplyIds: ReadonlySet = new Set(), ): ThreadPanelData { if (!openThreadHeadId) { return { @@ -267,7 +294,10 @@ export function buildThreadPanelData( } const directChildrenByParentId = buildDirectChildrenByParentId(messages); - const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages); + const descendantStatsByMessageId = buildDescendantStatsByMessageId( + messages, + unreadReplyIds, + ); const normalizedThreadHead = normalizeHeadMessage(threadHead); const visibleReplies = buildVisibleThreadReplies({ openThreadHeadId, diff --git a/desktop/src/features/messages/lib/unreadMarker.test.mjs b/desktop/src/features/messages/lib/unreadMarker.test.mjs new file mode 100644 index 000000000..1134e70fa --- /dev/null +++ b/desktop/src/features/messages/lib/unreadMarker.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computeChannelUnreadMarker, + computeThreadUnreadMarker, +} from "./unreadMarker.ts"; + +function topLevel(id, createdAt) { + return { id, createdAt, author: "a", time: "", body: "", depth: 0 }; +} + +function reply(id, createdAt, parentId) { + return { id, createdAt, author: "a", time: "", body: "", depth: 1, parentId }; +} + +test("computeChannelUnreadMarker_emptyTimeline_returnsNoUnread", () => { + const marker = computeChannelUnreadMarker([], 100); + assert.equal(marker.firstUnreadMessageId, null); + assert.equal(marker.unreadCount, 0); +}); + +test("computeChannelUnreadMarker_nullFrontier_marksEveryTopLevelUnread", () => { + const messages = [topLevel("a", 10), topLevel("b", 20), topLevel("c", 30)]; + const marker = computeChannelUnreadMarker(messages, null); + assert.equal(marker.firstUnreadMessageId, "a"); + assert.equal(marker.unreadCount, 3); +}); + +test("computeChannelUnreadMarker_frontierBelowFirst_allUnread", () => { + const messages = [topLevel("a", 10), topLevel("b", 20)]; + const marker = computeChannelUnreadMarker(messages, 5); + assert.equal(marker.firstUnreadMessageId, "a"); + assert.equal(marker.unreadCount, 2); +}); + +test("computeChannelUnreadMarker_frontierBetweenMessages_marksOldestAfterFrontier", () => { + const messages = [topLevel("a", 10), topLevel("b", 20), topLevel("c", 30)]; + const marker = computeChannelUnreadMarker(messages, 15); + assert.equal(marker.firstUnreadMessageId, "b"); + assert.equal(marker.unreadCount, 2); +}); + +test("computeChannelUnreadMarker_frontierAtMessageTimestamp_isInclusive", () => { + // A message whose createdAt equals the frontier is considered read + // (strictly greater-than is unread), matching the read-marker semantics. + const messages = [topLevel("a", 10), topLevel("b", 20)]; + const marker = computeChannelUnreadMarker(messages, 20); + assert.equal(marker.firstUnreadMessageId, null); + assert.equal(marker.unreadCount, 0); +}); + +test("computeChannelUnreadMarker_frontierAtLatest_returnsNoUnread", () => { + const messages = [topLevel("a", 10), topLevel("b", 20)]; + const marker = computeChannelUnreadMarker(messages, 100); + assert.equal(marker.firstUnreadMessageId, null); + assert.equal(marker.unreadCount, 0); +}); + +test("computeChannelUnreadMarker_threadRepliesExcluded_onlyTopLevelCounted", () => { + // Thread replies (with parentId) are out of scope for the channel divider. + const messages = [ + topLevel("root", 10), + reply("r1", 25, "root"), + topLevel("b", 30), + ]; + const marker = computeChannelUnreadMarker(messages, 15); + assert.equal(marker.firstUnreadMessageId, "b"); + assert.equal(marker.unreadCount, 1); +}); + +test("computeChannelUnreadMarker_unreadAfterReadReplies_picksTopLevel", () => { + // A newer reply does not become the divider target even if it is unread. + const messages = [topLevel("a", 10), topLevel("b", 20), reply("r1", 50, "a")]; + const marker = computeChannelUnreadMarker(messages, 15); + assert.equal(marker.firstUnreadMessageId, "b"); + assert.equal(marker.unreadCount, 1); +}); + +test("computeChannelUnreadMarker_suppressed_returnsNoMarkerDespiteUnread", () => { + // Manually marking the channel unread suppresses the in-timeline marker so + // the pill/divider do not contradict the sidebar dot. Messages that would + // otherwise be unread (frontier below them) produce nothing when suppressed. + const messages = [topLevel("a", 10), topLevel("b", 20)]; + const marker = computeChannelUnreadMarker(messages, 5, true); + assert.equal(marker.firstUnreadMessageId, null); + assert.equal(marker.unreadCount, 0); +}); + +test("computeChannelUnreadMarker_suppressedNeverReadChannel_returnsNoMarker", () => { + // Suppression overrides the never-read (null frontier) case too. + const messages = [topLevel("a", 10), topLevel("b", 20)]; + const marker = computeChannelUnreadMarker(messages, null, true); + assert.equal(marker.firstUnreadMessageId, null); + assert.equal(marker.unreadCount, 0); +}); + +// --- computeThreadUnreadMarker tests --- + +test("computeThreadUnreadMarker_emptyReplies_returnsNoUnread", () => { + const marker = computeThreadUnreadMarker([], 100); + assert.equal(marker.firstUnreadReplyId, null); + assert.equal(marker.unreadCount, 0); +}); + +test("computeThreadUnreadMarker_nullFrontier_marksAllRepliesUnread", () => { + const replies = [ + { id: "r1", createdAt: 10 }, + { id: "r2", createdAt: 20 }, + { id: "r3", createdAt: 30 }, + ]; + const marker = computeThreadUnreadMarker(replies, null); + assert.equal(marker.firstUnreadReplyId, "r1"); + assert.equal(marker.unreadCount, 3); +}); + +test("computeThreadUnreadMarker_frontierBetweenReplies_countsAfterFrontier", () => { + const replies = [ + { id: "r1", createdAt: 10 }, + { id: "r2", createdAt: 20 }, + { id: "r3", createdAt: 30 }, + ]; + const marker = computeThreadUnreadMarker(replies, 15); + assert.equal(marker.firstUnreadReplyId, "r2"); + assert.equal(marker.unreadCount, 2); +}); + +test("computeThreadUnreadMarker_frontierAtReplyTimestamp_isRead", () => { + // A reply whose createdAt equals the frontier is considered read (strictly >). + const replies = [ + { id: "r1", createdAt: 10 }, + { id: "r2", createdAt: 20 }, + ]; + const marker = computeThreadUnreadMarker(replies, 20); + assert.equal(marker.firstUnreadReplyId, null); + assert.equal(marker.unreadCount, 0); +}); + +test("computeThreadUnreadMarker_frontierAboveAll_returnsNoUnread", () => { + const replies = [ + { id: "r1", createdAt: 10 }, + { id: "r2", createdAt: 20 }, + ]; + const marker = computeThreadUnreadMarker(replies, 100); + assert.equal(marker.firstUnreadReplyId, null); + assert.equal(marker.unreadCount, 0); +}); + +test("computeThreadUnreadMarker_frontierBelowAll_allUnread", () => { + const replies = [ + { id: "r1", createdAt: 10 }, + { id: "r2", createdAt: 20 }, + ]; + const marker = computeThreadUnreadMarker(replies, 5); + assert.equal(marker.firstUnreadReplyId, "r1"); + assert.equal(marker.unreadCount, 2); +}); + +test("computeThreadUnreadMarker_singleReplyUnread_countsOne", () => { + const replies = [ + { id: "r1", createdAt: 10 }, + { id: "r2", createdAt: 20 }, + { id: "r3", createdAt: 30 }, + ]; + const marker = computeThreadUnreadMarker(replies, 25); + assert.equal(marker.firstUnreadReplyId, "r3"); + assert.equal(marker.unreadCount, 1); +}); + +test("computeThreadUnreadMarker_emptyRepliesNullFrontier_returnsNoUnread", () => { + const marker = computeThreadUnreadMarker([], null); + assert.equal(marker.firstUnreadReplyId, null); + assert.equal(marker.unreadCount, 0); +}); diff --git a/desktop/src/features/messages/lib/unreadMarker.ts b/desktop/src/features/messages/lib/unreadMarker.ts new file mode 100644 index 000000000..4e915a20c --- /dev/null +++ b/desktop/src/features/messages/lib/unreadMarker.ts @@ -0,0 +1,113 @@ +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Identifies the first unread top-level channel message relative to a read + * frontier captured when the channel was opened. + * + * "Unread" is defined against the open-time frontier, not the live read + * marker: opening a channel immediately advances the live marker to latest, + * so the divider must be computed from the snapshot taken before that + * advance. Thread replies (messages with a parent) are out of scope here — + * the channel divider marks top-level messages only. + */ +export type ChannelUnreadMarker = { + /** Event id of the oldest unread top-level message, or null if none. */ + firstUnreadMessageId: string | null; + /** Count of unread top-level messages at or after the first unread one. */ + unreadCount: number; +}; + +const EMPTY_MARKER: ChannelUnreadMarker = { + firstUnreadMessageId: null, + unreadCount: 0, +}; + +/** + * @param messages Timeline messages in chronological order. + * @param frontierSeconds Read frontier in unix seconds captured at channel + * open. `null` means the channel was never read, so every top-level message + * counts as unread. + * @param suppressed When true, the channel was manually marked unread this + * session; there is no meaningful in-timeline boundary, so no marker is + * produced regardless of the frontier. + */ +export function computeChannelUnreadMarker( + messages: TimelineMessage[], + frontierSeconds: number | null, + suppressed = false, +): ChannelUnreadMarker { + if (suppressed) { + return EMPTY_MARKER; + } + + let firstUnreadMessageId: string | null = null; + let unreadCount = 0; + + for (const message of messages) { + if (message.parentId) { + continue; + } + const isUnread = + frontierSeconds === null || message.createdAt > frontierSeconds; + if (!isUnread) { + continue; + } + if (firstUnreadMessageId === null) { + firstUnreadMessageId = message.id; + } + unreadCount += 1; + } + + return firstUnreadMessageId === null + ? EMPTY_MARKER + : { firstUnreadMessageId, unreadCount }; +} + +/** + * Thread-scoped unread marker. Counts replies newer than the thread read + * frontier and identifies the first unread reply. + * + * Unlike the channel marker, every entry is a reply (no parentId filter) and + * there is no suppression mechanism. + */ +export type ThreadUnreadMarker = { + /** Event id of the oldest unread reply, or null if none. */ + firstUnreadReplyId: string | null; + /** Count of unread replies at or after the first unread one. */ + unreadCount: number; +}; + +const EMPTY_THREAD_MARKER: ThreadUnreadMarker = { + firstUnreadReplyId: null, + unreadCount: 0, +}; + +/** + * @param replies Thread replies in chronological order. + * @param frontierSeconds Read frontier in unix seconds captured at thread + * open. `null` means the thread was never read, so every reply counts as + * unread. + */ +export function computeThreadUnreadMarker( + replies: Pick[], + frontierSeconds: number | null, +): ThreadUnreadMarker { + let firstUnreadReplyId: string | null = null; + let unreadCount = 0; + + for (const reply of replies) { + const isUnread = + frontierSeconds === null || reply.createdAt > frontierSeconds; + if (!isUnread) { + continue; + } + if (firstUnreadReplyId === null) { + firstUnreadReplyId = reply.id; + } + unreadCount += 1; + } + + return firstUnreadReplyId === null + ? EMPTY_THREAD_MARKER + : { firstUnreadReplyId, unreadCount }; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 5e8bad43d..76b0cbb4a 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -27,6 +27,7 @@ import { MessageComposer } from "./MessageComposer"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; +import { UnreadDivider } from "./UnreadDivider"; import { useComposerHeightPadding } from "./useComposerHeightPadding"; import { useTimelineScrollManager } from "./useTimelineScrollManager"; @@ -37,6 +38,8 @@ type MessageThreadPanelProps = { channelName: string; currentPubkey?: string; disabled?: boolean; + /** Event id of the first unread reply, or null/undefined if all read. */ + firstUnreadReplyId?: string | null; layout?: "standalone" | "split"; editTarget?: { author: string; @@ -72,6 +75,8 @@ type MessageThreadPanelProps = { scrollTargetId: string | null; threadHead: TimelineMessage | null; threadReplies: MainTimelineEntry[]; + /** Subtree unread counts for collapsed summary rows, keyed by reply id. */ + threadReplyUnreadCounts?: ReadonlyMap; threadTypingPubkeys: string[]; toolbarExtraActions?: React.ReactNode; widthPx: number; @@ -98,6 +103,7 @@ export function MessageThreadPanel({ channelName, currentPubkey, disabled = false, + firstUnreadReplyId, layout = "standalone", editTarget, isSending, @@ -123,6 +129,7 @@ export function MessageThreadPanel({ scrollTargetId, threadHead, threadReplies, + threadReplyUnreadCounts, threadTypingPubkeys, toolbarExtraActions, widthPx, @@ -221,7 +228,9 @@ export function MessageThreadPanel({
{threadReplies.length > 0 ? (
- {threadReplies.map((entry) => { + {threadReplies.map((entry, index) => { + const showUnreadDivider = + index > 0 && entry.message.id === firstUnreadReplyId; return (
+ {showUnreadDivider ? : null} ) : null}
diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index 6103bb403..f8c009454 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -45,11 +45,13 @@ export function MessageThreadSummaryRow({ message, onOpenThread, summary, + unreadCount, }: { depth?: number; message: TimelineMessage; onOpenThread: (message: TimelineMessage) => void; summary: TimelineThreadSummary; + unreadCount?: number; }) { const visibleDepth = Math.min(Math.max(depth, 0), 6); const indentPx = @@ -116,6 +118,14 @@ export function MessageThreadSummaryRow({ {summary.replyCount} {replyLabel} + {unreadCount != null && unreadCount > 0 ? ( + + {unreadCount} + + ) : null} {summary.lastReplyAt ? ( <> diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 1fa347a20..eb3cbe792 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ArrowDown, Hash } from "lucide-react"; +import { ArrowDown, ArrowUp, Hash } from "lucide-react"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -70,6 +70,12 @@ type MessageTimelineProps = { searchQuery?: string; targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; + /** Event id of the oldest unread top-level message at channel open, or null. */ + firstUnreadMessageId?: string | null; + /** Count of unread top-level messages at channel open. */ + unreadCount?: number; + /** Per-thread unread counts keyed by thread root id. */ + threadUnreadCounts?: ReadonlyMap; }; type ChannelIntroAction = { @@ -123,6 +129,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ searchQuery, targetMessageId = null, onTargetReached, + firstUnreadMessageId = null, + unreadCount = 0, + threadUnreadCounts, }: MessageTimelineProps) { const internalScrollRef = React.useRef(null); const scrollContainerRef = externalScrollRef ?? internalScrollRef; @@ -139,6 +148,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ newMessageCount, restoreScrollPosition, scrollToBottom, + scrollToMessage, syncScrollState, } = useTimelineScrollManager({ channelId, @@ -149,6 +159,39 @@ export const MessageTimeline = React.memo(function MessageTimeline({ targetMessageId, }); + // The unread pill is a transient, per-open affordance: dismiss it once the + // user acts on it (jumps to the oldest unread) or catches up by reaching the + // bottom of the timeline. Reset when the channel changes so a freshly opened + // channel shows its own pill. + const [isUnreadPillDismissed, setIsUnreadPillDismissed] = + React.useState(false); + // Track whether the pill has been shown at least once this channel visit. + // This prevents the dismiss effect from firing on mount (when isAtBottom + // initializes as true) before the pill ever renders. + const hasShownPillRef = React.useRef(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on channel switch only + React.useEffect(() => { + setIsUnreadPillDismissed(false); + hasShownPillRef.current = false; + }, [channelId]); + React.useEffect(() => { + if (isAtBottom && hasShownPillRef.current) { + setIsUnreadPillDismissed(true); + } + }, [isAtBottom]); + const showUnreadPill = + !isUnreadPillDismissed && + unreadCount > 0 && + firstUnreadMessageId !== null && + !isLoading; + if (showUnreadPill) hasShownPillRef.current = true; + const handleJumpToOldestUnread = React.useCallback(() => { + setIsUnreadPillDismissed(true); + if (firstUnreadMessageId) { + scrollToMessage(firstUnreadMessageId); + } + }, [firstUnreadMessageId, scrollToMessage]); + // Scroll to the active search match when it changes. const prevSearchActiveRef = React.useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref @@ -196,6 +239,26 @@ export const MessageTimeline = React.memo(function MessageTimeline({ return (
+ {showUnreadPill ? ( +
+ +
+ ) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index a649fa357..551d84d7b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -4,7 +4,10 @@ import { formatDayHeading, isSameDay, } from "@/features/messages/lib/dateFormatters"; -import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { + buildMainTimelineEntries, + shouldRenderUnreadDivider, +} from "@/features/messages/lib/threadPanel"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; @@ -15,6 +18,7 @@ import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; +import { UnreadDivider } from "./UnreadDivider"; type TimelineMessageListProps = { agentPubkeys?: ReadonlySet; @@ -22,6 +26,8 @@ type TimelineMessageListProps = { channelName?: string; channelType?: ChannelType | null; currentPubkey?: string; + /** Event id of the oldest unread top-level message; renders a "New" divider above it. */ + firstUnreadMessageId?: string | null; followThreadById?: (rootId: string) => void; highlightedMessageId?: string | null; isFollowingThreadById?: (rootId: string) => boolean; @@ -54,6 +60,8 @@ type TimelineMessageListProps = { searchMatchingMessageIds?: Set; /** The current find-in-channel query string. */ searchQuery?: string; + /** Per-thread unread counts keyed by thread root id. */ + threadUnreadCounts?: ReadonlyMap; }; function hasVideoAttachment(message: TimelineMessage): boolean { @@ -106,6 +114,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ channelName, channelType, currentPubkey, + firstUnreadMessageId = null, followThreadById, highlightedMessageId = null, isFollowingThreadById, @@ -123,6 +132,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + threadUnreadCounts, unfollowThreadById, }: TimelineMessageListProps) { const entries = React.useMemo( @@ -205,6 +215,16 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ dayGroups.push(currentDayGroup); } + // The unread "New" divider only marks a read/unread boundary when there is + // a message above the first unread. When the first unread is the first + // rendered top-level entry (fresh/never-read channel), there is nothing + // above to separate from, so it is suppressed. + if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) { + currentDayGroup?.elements.push( + , + ); + } + if (message.kind === KIND_SYSTEM_MESSAGE) { const footer = messageFooters?.[message.id] ?? null; currentDayGroup?.elements.push( @@ -272,6 +292,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ message={message} onOpenThread={onReply} summary={summary} + unreadCount={threadUnreadCounts?.get(message.id)} /> {footer}
, diff --git a/desktop/src/features/messages/ui/UnreadDivider.tsx b/desktop/src/features/messages/ui/UnreadDivider.tsx new file mode 100644 index 000000000..5994f08fc --- /dev/null +++ b/desktop/src/features/messages/ui/UnreadDivider.tsx @@ -0,0 +1,20 @@ +/** + * Inline "New" divider rendered directly above the oldest unread top-level + * message, mirroring Slack's read/unread boundary. Computed from the + * channel's read frontier as it stood when the channel was opened. + */ +export function UnreadDivider() { + return ( +
+
+ + New + +
+
+ ); +} diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index da2fd7ff6..ecd354eee 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -344,6 +344,55 @@ export function useTimelineScrollManager({ unpinFromBottom, ]); + // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes + const scrollToMessage = React.useCallback( + (messageId: string) => { + const timeline = timelineRef.current; + if (!timeline) { + return false; + } + + const targetElement = timeline.querySelector( + `[data-message-id="${messageId}"]`, + ); + if (!targetElement) { + return false; + } + + unpinFromBottom(timeline.scrollTop); + setHighlightedMessageId(messageId); + setNewMessageCount(0); + + const alignToTarget = (remainingFrames: number) => { + targetElement.scrollIntoView({ + block: "center", + behavior: "auto", + }); + previousScrollTopRef.current = timeline.scrollTop; + + if (remainingFrames > 0) { + requestAnimationFrame(() => { + alignToTarget(remainingFrames - 1); + }); + return; + } + + onTargetReached?.(messageId); + }; + + alignToTarget(2); + + window.setTimeout(() => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + }, 2_000); + + return true; + }, + [onTargetReached, unpinFromBottom], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes React.useEffect(() => { if (!targetMessageId) { @@ -356,52 +405,12 @@ export function useTimelineScrollManager({ return; } - const timeline = timelineRef.current; - if (!timeline) { - return; - } - - const targetElement = timeline.querySelector( - `[data-message-id="${targetMessageId}"]`, - ); - if (!targetElement) { + if (!scrollToMessage(targetMessageId)) { return; } handledTargetMessageIdRef.current = targetMessageId; - unpinFromBottom(timeline.scrollTop); - setHighlightedMessageId(targetMessageId); - setNewMessageCount(0); - - const alignToTarget = (remainingFrames: number) => { - targetElement.scrollIntoView({ - block: "center", - behavior: "auto", - }); - previousScrollTopRef.current = timeline.scrollTop; - - if (remainingFrames > 0) { - requestAnimationFrame(() => { - alignToTarget(remainingFrames - 1); - }); - return; - } - - onTargetReached?.(targetMessageId); - }; - - alignToTarget(2); - - const timeout = window.setTimeout(() => { - setHighlightedMessageId((current) => - current === targetMessageId ? null : current, - ); - }, 2_000); - - return () => { - window.clearTimeout(timeout); - }; - }, [isLoading, messages, onTargetReached, targetMessageId, unpinFromBottom]); + }, [isLoading, messages, scrollToMessage, targetMessageId]); return { bottomAnchorRef, @@ -411,6 +420,7 @@ export function useTimelineScrollManager({ newMessageCount, restoreScrollPosition, scrollToBottom, + scrollToMessage, syncScrollState, }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b98dc9e80..bb1d98f19 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -580,6 +580,7 @@ declare global { kind?: number; mentionPubkeys?: string[]; extraTags?: string[][]; + createdAt?: number; }) => RelayEvent; __BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: { channelName: string; @@ -2287,6 +2288,7 @@ function emitMockChannelMessage( kind?: number, mentionPubkeys?: string[], extraTags?: string[][], + createdAt?: number, ) { const eventKind = kind ?? 9; if (!parentEventId) { @@ -2296,7 +2298,7 @@ function emitMockChannelMessage( pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, ); if (extraTags) tags.push(...extraTags); - const event = createMockEvent(eventKind, content, tags, pubkey); + const event = createMockEvent(eventKind, content, tags, pubkey, createdAt); recordMockMessage(channelId, event); emitMockLiveEvent(channelId, event); return event; @@ -2321,7 +2323,13 @@ function emitMockChannelMessage( mentionPubkeys, ); if (extraTags) tags.push(...extraTags); - const event = createMockEvent(eventKind, content, tags, authorPubkey); + const event = createMockEvent( + eventKind, + content, + tags, + authorPubkey, + createdAt, + ); recordMockMessage(channelId, event); emitMockLiveEvent(channelId, event); return event; @@ -5807,6 +5815,7 @@ export function maybeInstallE2eTauriMocks() { kind, mentionPubkeys, extraTags, + createdAt, }) => { const channel = mockChannels.find( (candidate) => candidate.name === channelName, @@ -5823,6 +5832,7 @@ export function maybeInstallE2eTauriMocks() { kind, mentionPubkeys, extraTags, + createdAt, ); }; window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => { diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 610f174e0..3eca70f3a 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -404,7 +404,7 @@ test("opens a single-level thread panel with inline expansion", async ({ const siblingReply = `Sibling threaded reply ${timestamp}`; const nestedReply = `Nested threaded reply ${timestamp}`; const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`; - const nestedReplyVisibleTopMaxPx = 280; + const nestedReplyVisibleTopMaxPx = 300; const fillerReplies = Array.from( { length: 14 }, (_, index) => `Thread filler reply ${index} ${timestamp}`, diff --git a/desktop/tests/e2e/thread-unread-screenshots.spec.ts b/desktop/tests/e2e/thread-unread-screenshots.spec.ts new file mode 100644 index 000000000..6689c1670 --- /dev/null +++ b/desktop/tests/e2e/thread-unread-screenshots.spec.ts @@ -0,0 +1,445 @@ +import { expect, test } from "@playwright/test"; + +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/thread-unread"; + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(async () => { + return page.evaluate( + ({ ch }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? + false, + { ch: channelName }, + ); + }) + .toBe(true); +} + +function emitMockMessage( + page: import("@playwright/test").Page, + channelName: string, + content: string, + options?: { + parentEventId?: string; + pubkey?: string; + createdAt?: number; + }, +) { + return page.evaluate( + ({ ch, msg, parentEventId, pubkey, ts }) => { + return ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + parentEventId?: string | null; + pubkey?: string; + createdAt?: number; + }) => { id: string; created_at: number; pubkey: string }; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: ch, + content: msg, + parentEventId: parentEventId ?? undefined, + pubkey: pubkey ?? undefined, + createdAt: ts, + }); + }, + { + ch: channelName, + msg: content, + parentEventId: options?.parentEventId ?? null, + pubkey: options?.pubkey ?? TEST_IDENTITIES.alice.pubkey, + ts: options?.createdAt, + }, + ); +} + +// Unread thread replies must be dated strictly after the read frontier captured +// when the thread was last open. A minute ahead ensures they land past it. +const UNREAD_OFFSET_SECONDS = 60; + +function unreadTimestamp() { + return Math.floor(Date.now() / 1000) + UNREAD_OFFSET_SECONDS; +} + +// Nested replies are collapsed behind a summary row that carries the parent's +// id (data-thread-head-id). Expanding one level renders that reply's direct +// children, so the rendered count MUST grow after the click — asserting that +// ties the test to genuine rendered depth: a no-op expansion fails here rather +// than passing silently. A level can reveal several children at once (a +// branch), so the check is "grew", not "grew by one". +async function expandReply( + page: import("@playwright/test").Page, + replyId: string, +) { + const replies = page + .getByTestId("message-thread-replies") + .getByTestId("message-row"); + const before = await replies.count(); + await page.locator(`[data-thread-head-id="${replyId}"]`).click(); + await expect.poll(() => replies.count()).toBeGreaterThan(before); +} + +test.describe("thread unread indicator screenshots", () => { + test("01-thread-unread-badge", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + // Open general — catch-up adds mock-general-welcome to authoredRootIds + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + // Emit an initial reply so the thread summary row appears + await emitMockMessage(page, "general", "First reply to welcome", { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: Math.floor(Date.now() / 1000) - 10, + }); + + // Open the thread to establish a read frontier, then close it + const threadSummary = page.getByTestId("message-thread-summary").first(); + await expect(threadSummary).toBeVisible(); + await threadSummary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await page.getByTestId("message-thread-close").click(); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + + // Switch away so general becomes inactive + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // Emit new thread replies (these will be unread) + const base = unreadTimestamp(); + for (let i = 0; i < 3; i++) { + await emitMockMessage(page, "general", `Unread reply ${i + 1}`, { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: base + i, + }); + } + + // Switch back — thread summary should show unread badge + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const badge = page.getByTestId("thread-unread-badge"); + await expect(badge).toBeVisible(); + await expect(badge).toContainText("3"); + + await page.screenshot({ + path: `${SHOTS}/01-thread-unread-badge.png`, + }); + }); + + test("02-thread-new-divider", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + // Emit an initial reply so the thread summary appears + await emitMockMessage(page, "general", "Earlier reply", { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: Math.floor(Date.now() / 1000) - 10, + }); + + // Open thread to establish frontier, then close + const threadSummary = page.getByTestId("message-thread-summary").first(); + await expect(threadSummary).toBeVisible(); + await threadSummary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await page.getByTestId("message-thread-close").click(); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + + // Switch away + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // Emit new unread replies + const base = unreadTimestamp(); + for (let i = 0; i < 2; i++) { + await emitMockMessage(page, "general", `New reply ${i + 1}`, { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: base + i, + }); + } + + // Switch back and open the thread panel + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId("message-thread-summary").first().click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + + // The unread divider should appear above the first unread reply + // (not at index 0 since there's a read reply before the unread ones) + const divider = page.getByTestId("message-unread-divider"); + await expect(divider).toBeVisible(); + await divider.scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + + await page.screenshot({ + path: `${SHOTS}/02-thread-new-divider.png`, + }); + }); + + test("03-thread-no-badge-casual-browse", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + // Emit a root message from alice (tyler has NO stake in this thread) + const rootEvent = await emitMockMessage( + page, + "general", + "Alice starts a discussion", + { + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: Math.floor(Date.now() / 1000) - 30, + }, + ); + + // Emit replies from bob to alice's thread (tyler still has no stake) + const base = unreadTimestamp(); + for (let i = 0; i < 2; i++) { + await emitMockMessage(page, "general", `Bob chimes in ${i + 1}`, { + parentEventId: rootEvent!.id, + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: base + i, + }); + } + + // Wait for thread summary to render + await page.waitForTimeout(500); + + // The thread summary should NOT show an unread badge — tyler has no + // notification interest in alice's thread (not participated/authored/followed) + const badges = page.getByTestId("thread-unread-badge"); + await expect(badges).toHaveCount(0); + + await page.screenshot({ + path: `${SHOTS}/03-thread-no-badge-casual-browse.png`, + }); + }); + + test("04-thread-deep-nested-unread", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + // Build a genuinely nested branch by chaining parentEventId: each reply's + // id becomes the next reply's parent, so threadPanel increments depth per + // level and renders progressive indentation. The first three levels are + // dated in the past — they are the "already read" structure. + const past = Math.floor(Date.now() / 1000) - 60; + const r1 = await emitMockMessage( + page, + "general", + "Kicking off the design", + { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: past, + }, + ); + const r2 = await emitMockMessage( + page, + "general", + "Replying one level down", + { + parentEventId: r1!.id, + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: past + 1, + }, + ); + // A sibling at r1's level so the tree reads as a branching discussion. + await emitMockMessage(page, "general", "Separate angle on the same point", { + parentEventId: r1!.id, + pubkey: TEST_IDENTITIES.charlie.pubkey, + createdAt: past + 2, + }); + const r3 = await emitMockMessage(page, "general", "Going deeper still", { + parentEventId: r2!.id, + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: past + 3, + }); + + // Open the thread on the welcome root, expand the read structure + // (r1 → r2; r3 is a leaf until r4/r5 arrive), then close. This sets the + // read frontier over everything that currently exists. + const summary = page.getByTestId("message-thread-summary").first(); + await expect(summary).toBeVisible(); + await summary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expandReply(page, r1!.id); + await expandReply(page, r2!.id); + await page.getByTestId("message-thread-close").click(); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + + // Switch away, then emit the deeper replies past the frontier — these are + // the unread ones living inside the nested structure. + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + const base = unreadTimestamp(); + const r4 = await emitMockMessage(page, "general", "New nested follow-up", { + parentEventId: r3!.id, + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: base, + }); + await emitMockMessage(page, "general", "Deepest unread reply", { + parentEventId: r4!.id, + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: base + 1, + }); + + // Switch back, open the thread, and expand every level down to the + // unread tail. Each expandReply asserts a row appeared, so green here + // means the nesting genuinely rendered — not just that a divider exists. + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId("message-thread-summary").first().click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expandReply(page, r1!.id); + await expandReply(page, r2!.id); + await expandReply(page, r3!.id); + await expandReply(page, r4!.id); + + // Fully expanded: r1, r2, sibling, r3, r4, r5 — six rendered replies. + const replies = page + .getByTestId("message-thread-replies") + .getByTestId("message-row"); + await expect(replies).toHaveCount(6); + + const divider = page.getByTestId("message-unread-divider"); + await expect(divider).toBeVisible(); + await divider.scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + + await page.screenshot({ + path: `${SHOTS}/04-thread-deep-nested-unread.png`, + }); + }); + + test("05-thread-in-panel-subtree-badge", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + // A branch p (with a child c) plus a leaf sibling of p, all dated in the + // past so they form the "already read" structure. p keeps a child, so its + // in-panel row renders as a collapsible summary that can carry a subtree + // badge; the leaf sibling proves the panel shows other rows too. + const past = Math.floor(Date.now() / 1000) - 60; + const p = await emitMockMessage(page, "general", "Branch parent", { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: past, + }); + const c = await emitMockMessage(page, "general", "Child of branch parent", { + parentEventId: p!.id, + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: past + 1, + }); + await emitMockMessage(page, "general", "Sibling branch at top level", { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.charlie.pubkey, + createdAt: past + 2, + }); + + // Open the thread to snapshot the read frontier over the existing + // structure, then close. p stays collapsed — its summary row must remain a + // collapsed branch for the subtree badge to render. + const summary = page.getByTestId("message-thread-summary").first(); + await expect(summary).toBeVisible(); + await summary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await page.getByTestId("message-thread-close").click(); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + + // Switch away, then emit two unread replies deep under p (children of c) — + // p's subtree gains unread descendants while p itself stays collapsed. + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + const base = unreadTimestamp(); + const c2 = await emitMockMessage( + page, + "general", + "Unread under the branch", + { + parentEventId: c!.id, + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: base, + }, + ); + await emitMockMessage(page, "general", "Another unread under the branch", { + parentEventId: c2!.id, + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: base + 1, + }); + + // Switch back and open the panel WITHOUT expanding p. The collapsed p row + // must show its subtree unread count (the two unread descendants). + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId("message-thread-summary").first().click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + + // p renders as a collapsed summary row (it has a child); the sibling is a + // leaf and renders as a plain row, not a summary. Gate on p's summary row + // first — green here means the branch genuinely rendered, so the badge + // assertion below is read off a real collapsed row, not an empty panel. + const inPanelSummaries = page + .getByTestId("message-thread-replies") + .getByTestId("message-thread-summary"); + await expect(inPanelSummaries).toHaveCount(1); + + // Scope to message-thread-replies: this is the in-panel per-branch badge, + // NOT the depth-0 channel-timeline badge that lives outside the container. + // Against pre-2.5 code the in-panel badge was hard-0, so this fails there. + const inPanelBadge = page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"); + await expect(inPanelBadge).toBeVisible(); + await expect(inPanelBadge).toContainText("2"); + + await page.screenshot({ + path: `${SHOTS}/05-thread-in-panel-subtree-badge.png`, + }); + + // Expanding p marks its whole subtree read; the descendant-inclusive gate + // (Phase 2.5) drops the badge from p and every revealed row beneath it. + await expandReply(page, p!.id); + await expect(inPanelBadge).toHaveCount(0); + + await page.screenshot({ + path: `${SHOTS}/06-thread-expand-clears-subtree-badge.png`, + }); + }); +}); diff --git a/desktop/tests/e2e/unread-pill-screenshots.spec.ts b/desktop/tests/e2e/unread-pill-screenshots.spec.ts new file mode 100644 index 000000000..80239c440 --- /dev/null +++ b/desktop/tests/e2e/unread-pill-screenshots.spec.ts @@ -0,0 +1,231 @@ +import { expect, test } from "@playwright/test"; + +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/unread-pill"; + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(async () => { + return page.evaluate( + ({ ch }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? + false, + { ch: channelName }, + ); + }) + .toBe(true); +} + +function emitMockMessage( + page: import("@playwright/test").Page, + channelName: string, + content: string, + createdAt?: number, +) { + return page.evaluate( + ({ ch, msg, pubkey, ts }) => { + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + pubkey: string; + createdAt?: number; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: ch, + content: msg, + pubkey, + createdAt: ts, + }); + }, + { + ch: channelName, + msg: content, + pubkey: TEST_IDENTITIES.alice.pubkey, + ts: createdAt, + }, + ); +} + +// Unread messages must be created strictly after the read frontier captured +// when the channel was last open. The frontier is captured at the current +// second on open, and computeChannelUnreadMarker uses a strict +// `createdAt > frontier` predicate — so emitting at the same wall-clock second +// leaves the messages on the read side and the pill/divider never render. +// Dating them a minute ahead puts them deterministically past the frontier. +const UNREAD_OFFSET_SECONDS = 60; + +function unreadTimestamp() { + return Math.floor(Date.now() / 1000) + UNREAD_OFFSET_SECONDS; +} + +// Emit `count` unread messages to general, staggered one second apart so they +// sort deterministically and all land strictly past the read frontier. +async function emitUnreadMessages( + page: import("@playwright/test").Page, + count: number, +) { + const base = unreadTimestamp(); + for (let index = 0; index < count; index += 1) { + await emitMockMessage( + page, + "general", + `Unread message ${index + 1}`, + base + index, + ); + } +} + +// Scroll the timeline up so the viewport is no longer pinned to the bottom. +// The pill auto-dismisses once the user reaches the bottom of the timeline, so +// it only stays rendered while scrolled up — which is the state these shots +// need to capture. Scrolling part-way (rather than to the very top) keeps real +// message context on screen instead of the channel's empty-state intro. +async function scrollTimelineUp(page: import("@playwright/test").Page) { + await page.getByTestId("message-timeline").evaluate((el) => { + el.scrollTop = Math.floor(el.scrollHeight * 0.35); + }); + await page.waitForTimeout(300); +} + +test.describe("unread pill & divider screenshots", () => { + test("01-unread-pill-visible", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + // Open general, then switch to random so general becomes inactive + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await emitUnreadMessages(page, 20); + + // Switch back to general — pill should appear + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Scroll up so the unreads sit below the fold: the pill is the + // "jump to oldest unread" affordance and only stays on screen while the + // user is scrolled away from the bottom of the timeline. + await scrollTimelineUp(page); + + const pill = page.getByTestId("message-unread-pill"); + await expect(pill).toBeVisible(); + await expect(pill).toContainText("20 new messages"); + + await page.screenshot({ + path: `${SHOTS}/01-unread-pill-visible.png`, + }); + }); + + test("02-unread-divider-visible", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await emitUnreadMessages(page, 3); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const divider = page.getByTestId("message-unread-divider"); + await expect(divider).toBeVisible(); + + // Scroll the divider into view for a clear screenshot + await divider.scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + + await page.screenshot({ + path: `${SHOTS}/02-unread-divider-visible.png`, + }); + }); + + test("03-pill-dismissed-after-scroll", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await emitUnreadMessages(page, 20); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Scroll up so the pill is showing, matching scenario 01's starting state. + await scrollTimelineUp(page); + + const pill = page.getByTestId("message-unread-pill"); + await expect(pill).toBeVisible(); + + // Click the pill to jump to the oldest unread, which dismisses it. + await pill.click(); + + // Pill should be dismissed + await expect(pill).toHaveCount(0); + + // Divider should still be visible + const divider = page.getByTestId("message-unread-divider"); + await expect(divider).toBeVisible(); + + await page.screenshot({ + path: `${SHOTS}/03-pill-dismissed-after-scroll.png`, + }); + }); + + test("04-mark-unread-suppresses-pill", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Mark channel unread via context menu on the sidebar item + await page.getByTestId("channel-general").click({ button: "right" }); + await page.getByText("Mark unread").click(); + + // Switch away and back to re-open the channel + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // The unread indicator only renders on inactive channels, so it appears + // once general is no longer the active channel. + await expect(page.getByTestId("channel-unread-general")).toBeVisible(); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Pill and divider should NOT appear (suppressed for forced-unread) + await expect(page.getByTestId("message-unread-pill")).toHaveCount(0); + await expect(page.getByTestId("message-unread-divider")).toHaveCount(0); + + await page.screenshot({ + path: `${SHOTS}/04-mark-unread-suppresses-pill.png`, + }); + }); +});