From 5ec6ebbb0bf9fd4b4d4fe64a3730f7393eb5881d Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 12 Jun 2026 19:31:42 -0400 Subject: [PATCH 1/5] feat(desktop): add thread unread indicators Extend the channel-level unread system (PR #1008) to threads: 1. Thread read-state model: getThreadReadAt/markThreadRead in AppShellContext delegate to ReadStateManager with thread: context keys, giving cross-device sync via NIP-RS for free. 2. Thread unread count badge: computeThreadUnreadMarker counts replies after the thread read frontier. MessageThreadSummaryRow renders a blue numeric badge when unreadCount > 0. 3. In-thread "New" divider: MessageThreadPanel captures the thread frontier on open (openFrontierRef pattern matching channel screen), computes firstUnreadReplyId, and renders UnreadDivider above it. Suppressed when the first unread is the first reply (index 0) to avoid a meaningless boundary. Mark-read fires immediately on thread panel open using the latest reply timestamp. Thread with 0 replies is a no-op. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/AppShell.tsx | 14 ++++ desktop/src/app/AppShellContext.tsx | 7 ++ .../src/features/channels/ui/ChannelPane.tsx | 8 ++ .../features/channels/ui/ChannelScreen.tsx | 65 ++++++++++++++- .../messages/lib/unreadMarker.test.mjs | 80 ++++++++++++++++++- .../src/features/messages/lib/unreadMarker.ts | 49 ++++++++++++ .../messages/ui/MessageThreadPanel.tsx | 10 ++- .../messages/ui/MessageThreadSummaryRow.tsx | 10 +++ .../features/messages/ui/MessageTimeline.tsx | 4 + .../messages/ui/TimelineMessageList.tsx | 4 + 10 files changed, 248 insertions(+), 3 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index d1ef1790b..da6897977 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -389,6 +389,18 @@ export function AppShell() { 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) // so it can consume the NIP-RS read-state lifted from the single // ReadStateManager mounted via useUnreadChannels above. Channel-backed @@ -733,6 +745,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/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 69242cc65..a9e0947bd 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -131,6 +131,10 @@ type ChannelPaneProps = { threadTypingPubkeys: string[]; threadReplyTargetMessage: TimelineMessage | null; threadScrollTargetId: string | null; + /** Per-thread unread counts keyed by thread root id. */ + threadUnreadCounts?: ReadonlyMap; + /** Event id of the first unread reply in the open thread panel. */ + threadFirstUnreadReplyId?: string | null; targetMessageId: string | null; typingPubkeys: string[]; isFollowingThread?: boolean; @@ -256,6 +260,8 @@ export const ChannelPane = React.memo(function ChannelPane({ threadScrollTargetId, threadTypingPubkeys, threadReplyTargetMessage, + threadUnreadCounts, + threadFirstUnreadReplyId, typingPubkeys, }: ChannelPaneProps) { const timelineScrollRef = React.useRef(null); @@ -699,6 +705,7 @@ export const ChannelPane = React.memo(function ChannelPane({ searchMatchingMessageIds={channelFind.matchingMessageIds} searchQuery={channelFind.query} targetMessageId={targetMessageId} + threadUnreadCounts={threadUnreadCounts} /> {isNonMemberView ? (
()); + 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 (advance frontier to latest reply). + React.useEffect(() => { + if (!openThreadHeadId || threadMessages.length === 0) return; + const latestReply = threadMessages[threadMessages.length - 1].message; + markThreadRead(openThreadHeadId, latestReply.createdAt); + }, [openThreadHeadId, threadMessages, markThreadRead]); + // 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], + ); + // Compute per-thread unread counts for summary rows in the main timeline. + // eslint-disable-next-line react-hooks/exhaustive-deps -- readStateVersion is the invalidation signal + const threadUnreadCounts = React.useMemo(() => { + const counts = new Map(); + for (const message of timelineMessages) { + if (message.parentId) 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, readStateVersion]); const editTargetMessage = React.useMemo( () => timelineMessages.find((message) => message.id === editTargetId) ?? null, @@ -718,6 +779,8 @@ export function ChannelScreen({ threadTypingPubkeys={threadTypingPubkeys} threadReplyTargetMessage={threadReplyTargetMessage} threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadFirstUnreadReplyId={threadFirstUnreadReplyId} isJoining={joinChannelMutation.isPending} onJoinChannel={joinChannelMutation.mutateAsync} typingPubkeys={humanTypingPubkeys} diff --git a/desktop/src/features/messages/lib/unreadMarker.test.mjs b/desktop/src/features/messages/lib/unreadMarker.test.mjs index 278559462..c90ef7812 100644 --- a/desktop/src/features/messages/lib/unreadMarker.test.mjs +++ b/desktop/src/features/messages/lib/unreadMarker.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { computeChannelUnreadMarker } from "./unreadMarker.ts"; +import { computeChannelUnreadMarker, computeThreadUnreadMarker } from "./unreadMarker.ts"; function topLevel(id, createdAt) { return { id, createdAt, author: "a", time: "", body: "", depth: 0 }; @@ -91,3 +91,81 @@ test("computeChannelUnreadMarker_suppressedNeverReadChannel_returnsNoMarker", () 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 index b16328504..4e915a20c 100644 --- a/desktop/src/features/messages/lib/unreadMarker.ts +++ b/desktop/src/features/messages/lib/unreadMarker.ts @@ -62,3 +62,52 @@ export function computeChannelUnreadMarker( ? 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..9aa7473db 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; @@ -98,6 +101,7 @@ export function MessageThreadPanel({ channelName, currentPubkey, disabled = false, + firstUnreadReplyId, layout = "standalone", editTarget, isSending, @@ -221,7 +225,10 @@ export function MessageThreadPanel({
{threadReplies.length > 0 ? (
- {threadReplies.map((entry) => { + {threadReplies.map((entry, index) => { + const showUnreadDivider = + index > 0 && + entry.message.id === firstUnreadReplyId; return (
+ {showUnreadDivider ? : null} 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 d5e96827c..eb3cbe792 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -74,6 +74,8 @@ type MessageTimelineProps = { 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 = { @@ -129,6 +131,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onTargetReached, firstUnreadMessageId = null, unreadCount = 0, + threadUnreadCounts, }: MessageTimelineProps) { const internalScrollRef = React.useRef(null); const scrollContainerRef = externalScrollRef ?? internalScrollRef; @@ -444,6 +447,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ searchActiveMessageId={searchActiveMessageId} searchMatchingMessageIds={searchMatchingMessageIds} searchQuery={searchQuery} + threadUnreadCounts={threadUnreadCounts} unfollowThreadById={unfollowThreadById} />
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 7c4389479..551d84d7b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -60,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 { @@ -130,6 +132,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + threadUnreadCounts, unfollowThreadById, }: TimelineMessageListProps) { const entries = React.useMemo( @@ -289,6 +292,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ message={message} onOpenThread={onReply} summary={summary} + unreadCount={threadUnreadCounts?.get(message.id)} /> {footer}
, From 21ba0673c27af8787b61882bbaf919e0be5e87f7 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 12 Jun 2026 19:38:47 -0400 Subject: [PATCH 2/5] fix(desktop): gate thread read-state and add eviction strategy Only persist thread read-state for threads the user has notification interest in (participated, authored, or followed). Opening a thread with no stake no longer creates a context key, preventing unnecessary blob growth. Add bounded eviction in currentContexts(): when publishable contexts exceed 8,000, evict oldest thread:* entries by timestamp. Channel keys are never evicted. This prevents silent blob rejection when the 10,000 MAX_CONTEXTS cap is approached. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/readState/readStateManager.ts | 21 +++++++++++++++++++ .../features/channels/ui/ChannelScreen.tsx | 10 +++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) 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/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index fa47b990f..8858a2d91 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -423,11 +423,14 @@ export function ChannelScreen({ }; }, [openThreadHeadId]); // Mark thread read when the panel opens (advance frontier to latest reply). + // 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 || threadMessages.length === 0) return; + if (!isNotifiedForCurrentThread) return; const latestReply = threadMessages[threadMessages.length - 1].message; markThreadRead(openThreadHeadId, latestReply.createdAt); - }, [openThreadHeadId, threadMessages, markThreadRead]); + }, [openThreadHeadId, threadMessages, markThreadRead, isNotifiedForCurrentThread]); // Compute the in-thread "New" divider position from the open-time frontier. const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo( () => { @@ -440,11 +443,14 @@ export function ChannelScreen({ [openThreadHeadId, threadMessages, threadOpenFrontierSeconds], ); // 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. // eslint-disable-next-line react-hooks/exhaustive-deps -- readStateVersion is the invalidation signal 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, @@ -457,7 +463,7 @@ export function ChannelScreen({ } } return counts; - }, [timelineMessages, getThreadReadAt, readStateVersion]); + }, [timelineMessages, getThreadReadAt, isNotifiedForThread, readStateVersion]); const editTargetMessage = React.useMemo( () => timelineMessages.find((message) => message.id === editTargetId) ?? null, From 9f33f5f4a591af05b674b3285a7f7432ec04add4 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 12 Jun 2026 19:44:07 -0400 Subject: [PATCH 3/5] style(desktop): fix biome formatting and lint suppression Apply biome formatter rules: multi-line imports, multi-line dependency arrays, single-line short conditions. Replace eslint-disable comment with biome-ignore for useExhaustiveDependencies (readStateVersion is an intentional invalidation signal for stable callbacks). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/AppShell.tsx | 5 +- .../features/channels/ui/ChannelScreen.tsx | 48 ++++++++++++------- .../messages/lib/unreadMarker.test.mjs | 5 +- .../messages/ui/MessageThreadPanel.tsx | 3 +- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index da6897977..e45f9feef 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -396,7 +396,10 @@ export function AppShell() { const markThreadRead = React.useCallback( (rootId: string, timestamp: number) => { - markChannelRead(`thread:${rootId}`, new Date(timestamp * 1_000).toISOString()); + markChannelRead( + `thread:${rootId}`, + new Date(timestamp * 1_000).toISOString(), + ); }, [markChannelRead], ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 8858a2d91..14f3a297b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -34,7 +34,10 @@ import { formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; import { buildThreadPanelData } from "@/features/messages/lib/threadPanel"; -import { computeChannelUnreadMarker, computeThreadUnreadMarker } from "@/features/messages/lib/unreadMarker"; +import { + computeChannelUnreadMarker, + computeThreadUnreadMarker, +} from "@/features/messages/lib/unreadMarker"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; @@ -405,7 +408,10 @@ export function ChannelScreen({ // 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)) { + if ( + openThreadHeadId && + !threadOpenFrontierRef.current.has(openThreadHeadId) + ) { threadOpenFrontierRef.current.set( openThreadHeadId, getThreadReadAt(openThreadHeadId), @@ -430,22 +436,24 @@ export function ChannelScreen({ if (!isNotifiedForCurrentThread) return; const latestReply = threadMessages[threadMessages.length - 1].message; markThreadRead(openThreadHeadId, latestReply.createdAt); - }, [openThreadHeadId, threadMessages, markThreadRead, isNotifiedForCurrentThread]); + }, [ + openThreadHeadId, + threadMessages, + 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], - ); + 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]); // 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. - // eslint-disable-next-line react-hooks/exhaustive-deps -- readStateVersion is the invalidation signal + // 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) { @@ -457,13 +465,21 @@ export function ChannelScreen({ ); if (directReplies.length === 0) continue; const frontier = getThreadReadAt(message.id); - const { unreadCount } = computeThreadUnreadMarker(directReplies, frontier); + const { unreadCount } = computeThreadUnreadMarker( + directReplies, + frontier, + ); if (unreadCount > 0) { counts.set(message.id, unreadCount); } } return counts; - }, [timelineMessages, getThreadReadAt, isNotifiedForThread, readStateVersion]); + }, [ + timelineMessages, + getThreadReadAt, + isNotifiedForThread, + readStateVersion, + ]); const editTargetMessage = React.useMemo( () => timelineMessages.find((message) => message.id === editTargetId) ?? null, diff --git a/desktop/src/features/messages/lib/unreadMarker.test.mjs b/desktop/src/features/messages/lib/unreadMarker.test.mjs index c90ef7812..1134e70fa 100644 --- a/desktop/src/features/messages/lib/unreadMarker.test.mjs +++ b/desktop/src/features/messages/lib/unreadMarker.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { computeChannelUnreadMarker, computeThreadUnreadMarker } from "./unreadMarker.ts"; +import { + computeChannelUnreadMarker, + computeThreadUnreadMarker, +} from "./unreadMarker.ts"; function topLevel(id, createdAt) { return { id, createdAt, author: "a", time: "", body: "", depth: 0 }; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 9aa7473db..17891acd9 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -227,8 +227,7 @@ export function MessageThreadPanel({
{threadReplies.map((entry, index) => { const showUnreadDivider = - index > 0 && - entry.message.id === firstUnreadReplyId; + index > 0 && entry.message.id === firstUnreadReplyId; return (
Date: Fri, 12 Jun 2026 20:52:56 -0400 Subject: [PATCH 4/5] test(desktop): add thread unread indicator E2E screenshot spec Captures 3 screenshots for PR documentation: - Thread unread badge on summary row (participated thread) - New divider inside thread panel above unread replies - No badge for casual browsing (interest gate working) Follows the same pattern as unread-pill-screenshots.spec.ts. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 1 + .../e2e/thread-unread-screenshots.spec.ts | 225 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 desktop/tests/e2e/thread-unread-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index a5019b0d5..42a8493ee 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ "**/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/tests/e2e/thread-unread-screenshots.spec.ts b/desktop/tests/e2e/thread-unread-screenshots.spec.ts new file mode 100644 index 000000000..d207994f8 --- /dev/null +++ b/desktop/tests/e2e/thread-unread-screenshots.spec.ts @@ -0,0 +1,225 @@ +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; +} + +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`, + }); + }); +}); From 112e17013c6bc8b8c46b3483e28da966bf746c4e Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 12 Jun 2026 22:01:00 -0400 Subject: [PATCH 5/5] fix(desktop): adjust thread panel height threshold for UnreadDivider The nested reply visibility assertion (nestedReplyVisibleTopMaxPx) needs to accommodate the UnreadDivider that now renders above unread replies when reopening a thread with a read frontier. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/tests/e2e/messaging.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}`,