From d49019a81a6d0307c489c5481c9e622f3035b88e Mon Sep 17 00:00:00 2001 From: David Hamilton Date: Thu, 30 Jul 2026 10:58:16 -0400 Subject: [PATCH 1/3] feat(desktop): make thread reply notifications traceable Signed-off-by: David Hamilton --- desktop/src/app/AppShell.tsx | 3 +- .../useAppShellDesktopNotifications.test.mjs | 59 ++++++++ .../app/useAppShellDesktopNotifications.ts | 75 ++++++++++- .../features/channels/unreadChannelCounts.ts | 37 +++++ .../channels/unreadReadMarker.test.mjs | 127 ++++++++++++++++++ .../features/channels/useUnreadChannels.ts | 66 ++++----- .../messages/lib/threadUnreadPill.test.mjs | 55 ++++++++ .../features/messages/lib/threadUnreadPill.ts | 55 ++++++++ .../src/features/messages/ui/MessageRow.tsx | 14 ++ .../messages/ui/MessageThreadSummaryRow.tsx | 7 +- .../features/messages/ui/MessageTimeline.tsx | 53 ++++++++ .../messages/ui/TimelineMessageList.tsx | 3 + .../src/features/sidebar/ui/AppSidebar.tsx | 6 + .../sidebar/ui/AppSidebarPinnedHeader.tsx | 8 +- .../sidebar/ui/CustomChannelSection.tsx | 13 ++ .../features/sidebar/ui/SidebarSection.tsx | 50 ++++++- desktop/src/shared/ui/UnreadPill.tsx | 8 +- desktop/tests/e2e/badge.spec.ts | 5 +- desktop/tests/e2e/channel-sort.spec.ts | 2 + desktop/tests/e2e/thread-unread.spec.ts | 22 ++- 20 files changed, 611 insertions(+), 57 deletions(-) create mode 100644 desktop/src/app/useAppShellDesktopNotifications.test.mjs create mode 100644 desktop/src/features/messages/lib/threadUnreadPill.test.mjs create mode 100644 desktop/src/features/messages/lib/threadUnreadPill.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index abbbf29610..1652b68d48 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -330,6 +330,7 @@ export function AppShell() { unreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, + threadOnlyUnreadChannelIds: threadOnlyUnreadIds, unreadChannelNotificationCount, getEffectiveTimestamp: getChannelReadAt, getOwnTimestamp: getOwnReadAt, @@ -355,7 +356,6 @@ export function AppShell() { onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, followedRootIds, }); - const getThreadReadAt = React.useCallback( (rootId: string, channelId?: string | null) => { const threadReadAt = getOwnReadAt(`thread:${rootId}`); @@ -911,6 +911,7 @@ export function AppShell() { selectedView={selectedView} unreadChannelIds={unreadChannelIds} unreadChannelCounts={unreadChannelCounts} + threadOnlyUnreadChannelIds={threadOnlyUnreadIds} mutedChannelIds={mutedChannelIds} onMuteChannel={muteChannel} onUnmuteChannel={unmuteChannel} diff --git a/desktop/src/app/useAppShellDesktopNotifications.test.mjs b/desktop/src/app/useAppShellDesktopNotifications.test.mjs new file mode 100644 index 0000000000..6760241ae9 --- /dev/null +++ b/desktop/src/app/useAppShellDesktopNotifications.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + resolveThreadReplySenderName, + threadReplyNotificationTitle, +} from "./useAppShellDesktopNotifications.ts"; + +const PUBKEY = "a".repeat(64); + +test("thread reply title uses resolved sender name with channel", () => { + const senderName = resolveThreadReplySenderName(PUBKEY, { + displayName: "Maya", + nip05Handle: null, + }); + + assert.equal(senderName, "Maya"); + assert.equal( + threadReplyNotificationTitle(senderName, "#design"), + "Maya replied in #design", + ); +}); + +test("thread reply title falls back to Reply when profile is not cached", () => { + const senderName = resolveThreadReplySenderName(PUBKEY, undefined); + + assert.equal(senderName, undefined); + assert.equal( + threadReplyNotificationTitle(senderName, "#design"), + "Reply in #design", + ); +}); + +test("empty display name never leaks a truncated pubkey into the title", () => { + const senderName = resolveThreadReplySenderName(PUBKEY, { + displayName: " ", + nip05Handle: null, + }); + + assert.equal(senderName, undefined); + assert.equal(threadReplyNotificationTitle(senderName, null), "Reply"); +}); + +test("nip05 handle is used when display name is missing", () => { + const senderName = resolveThreadReplySenderName(PUBKEY, { + displayName: null, + nip05Handle: "maya@example.com", + }); + + assert.equal(senderName, "maya@example.com"); + assert.equal( + threadReplyNotificationTitle(senderName, "#design"), + "maya@example.com replied in #design", + ); +}); + +test("resolved sender title omits channel when channel is unresolved", () => { + assert.equal(threadReplyNotificationTitle("Maya", null), "Maya replied"); +}); diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 2266862cac..e43635f5b5 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + import { shouldBounceForChannelNotification, toSearchHit, @@ -21,7 +23,61 @@ import { playNotificationSound, resolveSlotSound, } from "@/features/notifications/lib/sound"; -import type { Channel, RelayEvent } from "@/shared/api/types"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import type { + Channel, + Profile, + RelayEvent, + UserProfileSummary, +} from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + +/** + * Resolve a sender's display name for the thread-reply toast title, mirroring + * the home-feed notification path (use-feed-desktop-notifications.ts): resolve + * via `resolveUserLabel`, then discard the truncated-pubkey fallback so a raw + * pubkey/npub never lands in an OS notification title. + * + * Returns `undefined` when no usable name exists (caller falls back to the + * bare "Reply" prefix). + */ +export function resolveThreadReplySenderName( + pubkey: string, + profile: + | Pick + | null + | undefined, +): string | undefined { + if (!profile) return undefined; + const label = resolveUserLabel({ + pubkey, + profiles: { + [normalizePubkey(pubkey)]: { + displayName: profile.displayName, + avatarUrl: null, + nip05Handle: profile.nip05Handle, + ownerPubkey: null, + }, + }, + preferResolvedSelfLabel: true, + }); + return label !== truncatePubkey(pubkey) ? label : undefined; +} + +/** + * Thread-reply toast title: `"{Sender} replied in #channel"` when the sender's + * profile is cached, otherwise today's `"Reply in #channel"` fallback. Channel + * label omitted when unresolved (see {@link formatNotificationTitle}). + */ +export function threadReplyNotificationTitle( + senderName: string | undefined, + channelLabel: string | null, +): string { + return formatNotificationTitle({ + prefix: senderName ? `${senderName} replied` : "Reply", + channelLabel, + }); +} export function useAppShellDesktopNotifications({ channels, @@ -40,6 +96,14 @@ export function useAppShellDesktopNotifications({ ) => Promise; pubkey?: string; }) { + // Sender names come from the shared react-query profile cache rather than a + // new prop from AppShell: `useUsersBatchQuery` (which backs message + // timelines, the home feed, and DM metadata) seeds a `["user-profile", pk]` + // entry for every profile it resolves, so reading it here is a pure cache + // hit. A miss just means the profile hasn't been fetched this session — the + // toast falls back to the nameless "Reply" prefix. + const queryClient = useQueryClient(); + const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { if (!shouldBounceForChannelNotification(event.tags)) return; @@ -103,11 +167,18 @@ export function useAppShellDesktopNotifications({ // channelLabel is "#name" for the toast title; channelName is the raw // name stored in the navigation target for click-through routing. const channelLabel = channelName ? `#${channelName}` : null; + const senderName = resolveThreadReplySenderName( + event.pubkey, + queryClient.getQueryData([ + "user-profile", + normalizePubkey(event.pubkey), + ]), + ); const body = truncateNotificationBody(event.content, "New reply"); const threadRootId = getThreadReference(event.tags).rootId ?? null; void sendDesktopNotification({ - title: formatNotificationTitle({ prefix: "Reply", channelLabel }), + title: threadReplyNotificationTitle(senderName, channelLabel), body, target: { channelId, diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts index cb33c62975..0e76dd7104 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -5,6 +5,8 @@ export type ObservedUnreadEvent = { createdAt: number; rootId: string | null; highPriority: boolean; + isDm: boolean; + isThreadedReply: boolean; countsTowardBadge: boolean; countsTowardAppBadge: boolean; }; @@ -23,6 +25,8 @@ export function makeObservedUnreadEvent(input: { createdAt: input.createdAt, rootId: input.rootId, highPriority: input.highPriority, + isDm, + isThreadedReply: input.isThreadedReply, countsTowardBadge: isDm || input.isThreadedReply || input.highPriority, countsTowardAppBadge: isDm || (!input.isThreadedReply && input.highPriority), @@ -120,6 +124,39 @@ export function countUnreadHighPriorityObservedEvents( return count; } +/** + * A badge event whose only reason for counting is a threaded reply: not a DM + * and not a mention/broadcast. A mention inside a thread is high-priority and + * is intentionally excluded — it keeps the solid numeric badge. + */ +export function isThreadOnlyBadgeObservedEvent( + event: ObservedUnreadEvent, +): boolean { + return event.isThreadedReply === true && !event.isDm && !event.highPriority; +} + +/** + * True when at least one badge-counting event is unread and ALL unread + * badge-counting events are thread-only — i.e. the channel's numeric badge is + * entirely explained by replies invisible in the channel scroll, so the + * sidebar can demote it to the thread-glyph badge. + */ +export function unreadBadgeObservedEventsAreThreadOnly( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): boolean { + if (!eventsById) return false; + let sawThreadOnly = false; + for (const event of eventsById.values()) { + if (!event.countsTowardBadge) continue; + const readAt = getReadAt(event); + if (readAt !== null && event.createdAt <= readAt) continue; + if (!isThreadOnlyBadgeObservedEvent(event)) return false; + sawThreadOnly = true; + } + return sawThreadOnly; +} + export function observedUnreadEventReadAt( event: ObservedUnreadEvent, channelReadAt: number | null, diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index e05e2ba0b2..722950c181 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -7,8 +7,11 @@ import { countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, + isThreadOnlyBadgeObservedEvent, + makeObservedUnreadEvent, observedUnreadEventReadAt, recordObservedUnreadEvent, + unreadBadgeObservedEventsAreThreadOnly, } from "./unreadChannelCounts.ts"; import { addThreadActivityItems, @@ -477,3 +480,127 @@ test("addThreadActivityItems keeps newest items when input is newest-first", () assert.equal(result.items[0].id, "reply-1"); assert.equal(result.items.at(-1).id, "reply-100"); }); + +function observedEvent({ + id, + createdAt, + channelType = "stream", + isThreadedReply = false, + highPriority = false, + rootId = null, +}) { + return makeObservedUnreadEvent({ + id, + createdAt, + rootId, + highPriority, + channelType, + isThreadedReply, + }); +} + +test("thread-only classification: plain thread reply qualifies", () => { + const event = observedEvent({ + id: "reply", + createdAt: 10, + isThreadedReply: true, + rootId: "root", + }); + assert.equal(isThreadOnlyBadgeObservedEvent(event), true); +}); + +test("thread-only classification: thread reply that is also a mention does not qualify", () => { + const event = observedEvent({ + id: "mention-reply", + createdAt: 10, + isThreadedReply: true, + highPriority: true, + rootId: "root", + }); + assert.equal(isThreadOnlyBadgeObservedEvent(event), false); +}); + +test("thread-only classification: DM reply does not qualify", () => { + const event = observedEvent({ + id: "dm-reply", + createdAt: 10, + channelType: "dm", + isThreadedReply: true, + rootId: "root", + }); + assert.equal(isThreadOnlyBadgeObservedEvent(event), false); +}); + +test("channel is thread-only when every unread badge event is a plain thread reply", () => { + const events = new Map([ + [ + "r1", + observedEvent({ + id: "r1", + createdAt: 10, + isThreadedReply: true, + rootId: "root", + }), + ], + [ + "r2", + observedEvent({ + id: "r2", + createdAt: 20, + isThreadedReply: true, + rootId: "root", + }), + ], + ]); + assert.equal( + unreadBadgeObservedEventsAreThreadOnly(events, () => null), + true, + ); +}); + +test("channel is not thread-only when an unread mention coexists with thread replies", () => { + const events = new Map([ + [ + "r1", + observedEvent({ + id: "r1", + createdAt: 10, + isThreadedReply: true, + rootId: "root", + }), + ], + ["m1", observedEvent({ id: "m1", createdAt: 20, highPriority: true })], + ]); + assert.equal( + unreadBadgeObservedEventsAreThreadOnly(events, () => null), + false, + ); +}); + +test("channel is not thread-only once its thread replies are read", () => { + const events = new Map([ + [ + "r1", + observedEvent({ + id: "r1", + createdAt: 10, + isThreadedReply: true, + rootId: "root", + }), + ], + ]); + assert.equal( + unreadBadgeObservedEventsAreThreadOnly(events, () => 10), + false, + ); +}); + +// Forced-unread channels record no observed events (the unread derivation +// short-circuits before the classifier); with no events the classifier must +// report not-thread-only so the solid badge treatment is kept. +test("channel without observed events (forced-unread) is not thread-only", () => { + assert.equal( + unreadBadgeObservedEventsAreThreadOnly(undefined, () => null), + false, + ); +}); diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a2376f9b7b..d13b318b5a 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -10,11 +10,12 @@ import { countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, makeObservedUnreadEvent, - mapsEqual, observedUnreadEventReadAt, recordObservedUnreadEvent, + unreadBadgeObservedEventsAreThreadOnly, type ObservedUnreadEvent, } from "@/features/channels/unreadChannelCounts"; +import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference"; import { useReadState } from "@/features/channels/readState/useReadState"; import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { @@ -125,14 +126,6 @@ export function resolveObservedUnreadRootId(tags: string[][]): string | null { return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId; } -function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { - if (a.size !== b.size) return false; - for (const item of a) { - if (!b.has(item)) return false; - } - return true; -} - export function useUnreadChannels( channels: Channel[], activeChannel: Channel | null, @@ -831,6 +824,7 @@ export function useUnreadChannels( return { unreadChannelIds: new Set(), highPriorityUnreadChannelIds: new Set(), + threadOnlyUnreadChannelIds: new Set(), unreadChannelCounts: new Map(), unreadChannelNotificationCount: 0, }; @@ -838,6 +832,7 @@ export function useUnreadChannels( const unread = new Set(); const highPriority = new Set(); + const threadOnly = new Set(); const counts = new Map(); let unreadChannelNotificationCount = 0; @@ -878,6 +873,18 @@ export function useUnreadChannels( readAtForObservedEvent, ); counts.set(channel.id, badgeCount); + if ( + unreadBadgeObservedEventsAreThreadOnly( + observedEvents, + readAtForObservedEvent, + ) + ) { + // Every unread badge event is a plain thread reply — the sidebar + // demotes the badge to the thread glyph. Forced-unread channels + // never reach here (they continue above) so they keep the solid + // badge. + threadOnly.add(channel.id); + } unreadChannelNotificationCount += countUnreadAppBadgeObservedEvents( observedEvents, readAtForObservedEvent, @@ -901,6 +908,7 @@ export function useUnreadChannels( return { unreadChannelIds: unread, highPriorityUnreadChannelIds: highPriority, + threadOnlyUnreadChannelIds: threadOnly, unreadChannelCounts: counts, unreadChannelNotificationCount, }; @@ -914,37 +922,16 @@ export function useUnreadChannels( readStateVersion, ]); - // Stabilize Set references: only replace when contents actually change, - // so downstream memos don't re-run on every render when sets are equal. - const prevUnreadRef = React.useRef>(new Set()); - const prevHighPriorityRef = React.useRef>(new Set()); - const prevUnreadCountsRef = React.useRef>( - new Map(), - ); - - const unreadChannelIds = setsEqual( - rawUnread.unreadChannelIds, - prevUnreadRef.current, - ) - ? prevUnreadRef.current - : rawUnread.unreadChannelIds; - prevUnreadRef.current = unreadChannelIds; - - const highPriorityUnreadChannelIds = setsEqual( + // Stabilize Set/Map references: only replace when contents actually + // change, so downstream memos don't re-run when contents are equal. + const unreadChannelIds = useStableSet(rawUnread.unreadChannelIds); + const highPriorityUnreadChannelIds = useStableSet( rawUnread.highPriorityUnreadChannelIds, - prevHighPriorityRef.current, - ) - ? prevHighPriorityRef.current - : rawUnread.highPriorityUnreadChannelIds; - prevHighPriorityRef.current = highPriorityUnreadChannelIds; - - const unreadChannelCounts = mapsEqual( - rawUnread.unreadChannelCounts, - prevUnreadCountsRef.current, - ) - ? prevUnreadCountsRef.current - : rawUnread.unreadChannelCounts; - prevUnreadCountsRef.current = unreadChannelCounts; + ); + const threadOnlyUnreadChannelIds = useStableSet( + rawUnread.threadOnlyUnreadChannelIds, + ); + const unreadChannelCounts = useStableMap(rawUnread.unreadChannelCounts); const unreadChannelNotificationCount = rawUnread.unreadChannelNotificationCount; @@ -994,6 +981,7 @@ export function useUnreadChannels( unreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, + threadOnlyUnreadChannelIds, unreadChannelNotificationCount, markAllChannelsRead, markChannelRead, diff --git a/desktop/src/features/messages/lib/threadUnreadPill.test.mjs b/desktop/src/features/messages/lib/threadUnreadPill.test.mjs new file mode 100644 index 0000000000..0901212ab8 --- /dev/null +++ b/desktop/src/features/messages/lib/threadUnreadPill.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computeThreadUnreadPillTarget, + threadUnreadPillLabel, +} from "./threadUnreadPill.ts"; + +function msg(id) { + return { id }; +} + +test("computeThreadUnreadPillTarget_noCounts_returnsEmpty", () => { + const target = computeThreadUnreadPillTarget([msg("a"), msg("b")], undefined); + assert.equal(target.totalUnreadReplies, 0); + assert.equal(target.oldestParentId, null); +}); + +test("computeThreadUnreadPillTarget_emptyMap_returnsEmpty", () => { + const target = computeThreadUnreadPillTarget([msg("a")], new Map()); + assert.equal(target.totalUnreadReplies, 0); + assert.equal(target.oldestParentId, null); +}); + +test("computeThreadUnreadPillTarget_sumsAndPicksOldestLoadedRoot", () => { + const target = computeThreadUnreadPillTarget( + [msg("a"), msg("b"), msg("c")], + new Map([ + ["b", 2], + ["c", 3], + ]), + ); + assert.equal(target.totalUnreadReplies, 5); + // Messages are chronological, so the first hit is the oldest root. + assert.equal(target.oldestParentId, "b"); +}); + +test("computeThreadUnreadPillTarget_ignoresZeroAndUnloadedRoots", () => { + const target = computeThreadUnreadPillTarget( + [msg("a"), msg("b")], + new Map([ + ["a", 0], + ["b", 1], + ["not-loaded", 7], + ]), + ); + // Roots outside the loaded window can't be jump targets and don't count. + assert.equal(target.totalUnreadReplies, 1); + assert.equal(target.oldestParentId, "b"); +}); + +test("threadUnreadPillLabel_pluralizesReplies", () => { + assert.equal(threadUnreadPillLabel(1), "1 new reply in threads"); + assert.equal(threadUnreadPillLabel(4), "4 new replies in threads"); +}); diff --git a/desktop/src/features/messages/lib/threadUnreadPill.ts b/desktop/src/features/messages/lib/threadUnreadPill.ts new file mode 100644 index 0000000000..a4c4bbf15c --- /dev/null +++ b/desktop/src/features/messages/lib/threadUnreadPill.ts @@ -0,0 +1,55 @@ +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Target for the floating "N new replies in threads" pill. + * + * The channel-level unread pill counts top-level messages only + * (`computeChannelUnreadMarker` skips replies by design), so a thread reply + * bolds the channel in the sidebar while leaving the scrollback with no + * visible cue. This computes the complementary signal: the total unread + * thread replies across the loaded window and the oldest thread root that + * holds one, so the pill can jump the user to the parent row (which carries + * the lit badge and accent pointing into the thread). + */ +export type ThreadUnreadPillTarget = { + /** Sum of unread replies across thread roots in the loaded window. */ + totalUnreadReplies: number; + /** Oldest loaded thread root with unread replies, or null if none. */ + oldestParentId: string | null; +}; + +const EMPTY_TARGET: ThreadUnreadPillTarget = { + totalUnreadReplies: 0, + oldestParentId: null, +}; + +/** + * @param messages Loaded timeline messages in chronological order — the pill + * may only target rows that are actually mounted, so the sum is scoped to + * the loaded window rather than the full per-channel badge counts. + * @param threadUnreadCounts Per-thread unread counts keyed by thread root id + * (see `computeThreadBadgeCounts`). + */ +export function computeThreadUnreadPillTarget( + messages: ReadonlyArray>, + threadUnreadCounts: ReadonlyMap | undefined, +): ThreadUnreadPillTarget { + if (!threadUnreadCounts || threadUnreadCounts.size === 0) { + return EMPTY_TARGET; + } + let totalUnreadReplies = 0; + let oldestParentId: string | null = null; + for (const message of messages) { + const count = threadUnreadCounts.get(message.id) ?? 0; + if (count <= 0) continue; + totalUnreadReplies += count; + if (oldestParentId === null) { + oldestParentId = message.id; + } + } + return { totalUnreadReplies, oldestParentId }; +} + +export function threadUnreadPillLabel(count: number) { + return `${count} new ${count === 1 ? "reply" : "replies"} in threads`; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index afcb3e863c..93104496fd 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -71,6 +71,7 @@ export const MessageRow = React.memo( huddleMemberPubkeysPending = false, actionBarPlacement = "floating", collapseDescendantsLabel, + hasUnreadThreadReplies = false, isFollowingThread, isContinuation = false, isUnread, @@ -99,6 +100,9 @@ export const MessageRow = React.memo( collapseDepthGuideActions?: ReadonlyArray; connectDescendants?: boolean; depthGuideDepths?: ReadonlyArray; + /** True when this message's thread has unread replies — renders a subtle + * left accent so the unread thread is visible in the channel scroll. */ + hasUnreadThreadReplies?: boolean; highlighted?: boolean; highlightDescendantRail?: boolean; highlightReplyConnector?: boolean; @@ -788,6 +792,15 @@ export const MessageRow = React.memo( data-testid="message-row" onAnimationEnd={handleEntranceAnimationEnd} > + {/* Real element, not a `before:` pseudo — `highlighted` already owns + the row's before: pseudo for the jump-highlight flash. */} + {hasUnreadThreadReplies ? ( + + ) : null} {isThreadReplyLayout ? ( <> {avatarGutterNode} @@ -842,6 +855,7 @@ export const MessageRow = React.memo( prev.collapseDescendantsLabel === next.collapseDescendantsLabel && prev.connectDescendants === next.connectDescendants && numberArrayEqual(prev.depthGuideDepths, next.depthGuideDepths) && + prev.hasUnreadThreadReplies === next.hasUnreadThreadReplies && prev.highlightDescendantRail === next.highlightDescendantRail && prev.highlighted === next.highlighted && prev.highlightReplyConnector === next.highlightReplyConnector && diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index 083faf04db..5b74f33526 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -246,8 +246,11 @@ export function MessageThreadSummaryRow({ {summary.replyCount} {replyLabel} {unreadCount != null && unreadCount > 0 ? ( - - ({unreadCount} new) + + {unreadCount} new ) : null} {summary.lastReplyAt ? ( diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index cc5fb1e3dc..9d9a07d07a 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -16,6 +16,10 @@ import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { Spinner } from "@/shared/ui/spinner"; import { TooltipProvider } from "@/shared/ui/tooltip"; +import { + computeThreadUnreadPillTarget, + threadUnreadPillLabel, +} from "@/features/messages/lib/threadUnreadPill"; import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; @@ -458,6 +462,8 @@ const MessageTimelineBase = React.forwardRef< // channel shows its own pill. const [isUnreadPillDismissed, setIsUnreadPillDismissed] = React.useState(false); + const [isThreadPillDismissed, setIsThreadPillDismissed] = + 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. @@ -465,6 +471,7 @@ const MessageTimelineBase = React.forwardRef< // biome-ignore lint/correctness/useExhaustiveDependencies: reset on channel switch only React.useEffect(() => { setIsUnreadPillDismissed(false); + setIsThreadPillDismissed(false); hasShownPillRef.current = false; }, [channelId]); React.useEffect(() => { @@ -485,6 +492,36 @@ const MessageTimelineBase = React.forwardRef< } }, [firstUnreadMessageId, jumpToMessage]); + // Thread-unread pill: the top-level pill counts top-level messages only, so + // a channel whose only unreads are thread replies shows nothing in the + // scrollback. When the top-level pill has nothing to show but the loaded + // window holds unread thread replies, offer a jump to the oldest thread + // root with unreads (its summary badge and row accent carry the signal into + // the thread). The top-level pill always takes precedence. + const threadUnreadPillTarget = React.useMemo( + () => computeThreadUnreadPillTarget(deferredMessages, threadUnreadCounts), + [deferredMessages, threadUnreadCounts], + ); + const showThreadUnreadPill = + !showUnreadPill && + !isThreadPillDismissed && + !showTimelineSkeleton && + threadUnreadPillTarget.oldestParentId !== null; + const handleJumpToOldestThreadUnread = React.useCallback(() => { + setIsThreadPillDismissed(true); + const parentId = threadUnreadPillTarget.oldestParentId; + if (!parentId) return; + jumpToMessage(parentId); + // Open the thread panel on the jump target so the unread replies are one + // gesture away instead of requiring a second click on the summary row. + if (onReply) { + const parent = deferredMessages.find( + (message) => message.id === parentId, + ); + if (parent) onReply(parent); + } + }, [deferredMessages, jumpToMessage, onReply, threadUnreadPillTarget]); + // Scroll to the active search match when it changes. `jumpToMessage` updates // the scroll anchor (so the post-commit restore won't yank the view back off // the match) and, when virtualized, converges on the target through the index @@ -672,6 +709,22 @@ const MessageTimelineBase = React.forwardRef< testId="message-unread-pill" /> + ) : showThreadUnreadPill ? ( +
+ +
) : null} {/* `isFetchingOlder` clears on fetch resolve, but rows paint a frame later (deferred snapshot / settle-gate hold) — keep the spinner up diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 3c35cb6d43..b78024054f 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -780,6 +780,8 @@ function MessageRowItem({ if (summary && onReply) { const isHighlighted = message.id === highlightedMessageId; + const hasUnreadThreadReplies = + (threadUnreadCounts?.get(message.id) ?? 0) > 0; return (
; unreadChannelIds: ReadonlySet; + threadOnlyUnreadChannelIds: ReadonlySet; communities: Community[]; onAddCommunity: (community: Community) => void; onAddCommunityOpenChange?: (open: boolean) => void; @@ -195,6 +196,7 @@ export function AppSidebar({ selectedView, unreadChannelCounts, unreadChannelIds, + threadOnlyUnreadChannelIds, communities, onAddCommunity, onAddCommunityOpenChange, @@ -650,6 +652,7 @@ export function AppSidebar({ title="Starred" unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} + threadOnlyUnreadChannelIds={threadOnlyUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} @@ -684,6 +687,7 @@ export function AppSidebar({ selectedChannelId={selectedChannelId} unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} + threadOnlyUnreadChannelIds={threadOnlyUnreadChannelIds} sections={channelSections} assignments={channelAssignments} isFirst={idx === 0} @@ -755,6 +759,7 @@ export function AppSidebar({ title="Channels" unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} + threadOnlyUnreadChannelIds={threadOnlyUnreadChannelIds} sections={channelSections} assignments={channelAssignments} onAssignChannel={assignChannel} @@ -794,6 +799,7 @@ export function AppSidebar({ title="Forums" unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} + threadOnlyUnreadChannelIds={threadOnlyUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index a673492ef1..3ce21bf963 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,6 +1,7 @@ import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; +import { cn } from "@/shared/lib/cn"; import { FeatureGate } from "@/shared/features"; import type { Channel, SearchHit } from "@/shared/api/types"; import { @@ -98,6 +99,11 @@ export function AppSidebarPrimaryMenu({ 0 && + "font-semibold text-sidebar-foreground hover:text-sidebar-foreground", + )} isActive={selectedView === "home"} onClick={onSelectHome} tooltip="Inbox" @@ -108,7 +114,7 @@ export function AppSidebarPrimaryMenu({ {homeBadgeCount > 0 ? ( {Math.min(homeBadgeCount, 99)} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 7e17ce02ca..6ec0c608cf 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -360,6 +360,7 @@ export function ChannelGroupSection({ title, unreadChannelCounts, unreadChannelIds, + threadOnlyUnreadChannelIds, sections, assignments, onAssignChannel, @@ -408,6 +409,7 @@ export function ChannelGroupSection({ title: string; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; + threadOnlyUnreadChannelIds?: ReadonlySet; hasUnread?: boolean; onMarkAllRead?: () => void; sections?: ChannelSection[]; @@ -440,6 +442,9 @@ export function ChannelGroupSection({ channel={channel} activeWorking={activeWorkingByChannelId?.get(channel.id)} hasUnread={unreadChannelIds.has(channel.id)} + hasThreadOnlyUnread={threadOnlyUnreadChannelIds?.has( + channel.id, + )} unreadCount={unreadChannelCounts.get(channel.id) ?? 0} isMuted={mutedChannelIds?.has(channel.id)} isActive={ @@ -453,6 +458,9 @@ export function ChannelGroupSection({ channel={channel} activeWorking={activeWorkingByChannelId?.get(channel.id)} hasUnread={unreadChannelIds.has(channel.id)} + hasThreadOnlyUnread={threadOnlyUnreadChannelIds?.has( + channel.id, + )} unreadCount={unreadChannelCounts.get(channel.id) ?? 0} isMuted={mutedChannelIds?.has(channel.id)} isActive={ @@ -550,6 +558,7 @@ export function CustomChannelSection({ selectedChannelId, unreadChannelCounts, unreadChannelIds, + threadOnlyUnreadChannelIds, sections, assignments, isFirst, @@ -587,6 +596,7 @@ export function CustomChannelSection({ selectedChannelId: string | null; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; + threadOnlyUnreadChannelIds?: ReadonlySet; sections: ChannelSection[]; assignments: Record; isFirst: boolean; @@ -743,6 +753,9 @@ export function CustomChannelSection({ channel.id, )} hasUnread={unreadChannelIds.has(channel.id)} + hasThreadOnlyUnread={threadOnlyUnreadChannelIds?.has( + channel.id, + )} unreadCount={ unreadChannelCounts.get(channel.id) ?? 0 } diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 3e39c60a6d..d8a8f52860 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -6,6 +6,7 @@ import { FileText, Hash, Lock, + MessagesSquare, X, } from "lucide-react"; @@ -82,6 +83,30 @@ function UnreadCountBadge({ ); } +function ThreadUnreadBadge({ + channelName, + className, + count, +}: { + channelName: string; + className?: string; + count: number; +}) { + return ( + + + ); +} + function UnreadDotBadge({ channelName, className, @@ -250,6 +275,7 @@ export function ChannelMenuButton({ label, isActive, hasUnread, + hasThreadOnlyUnread, unreadCount = 0, activeWorking, isMuted, @@ -261,6 +287,7 @@ export function ChannelMenuButton({ label?: string; isActive: boolean; hasUnread: boolean; + hasThreadOnlyUnread?: boolean; unreadCount?: number; activeWorking?: ActiveChannelTurnSummary; isMuted?: boolean; @@ -323,11 +350,19 @@ export function ChannelMenuButton({ ) : null} {hasUnread && !isActive && channel.channelType !== "dm" ? ( unreadCount > 0 ? ( - + hasThreadOnlyUnread ? ( + + ) : ( + + ) ) : ( ) @@ -349,6 +384,7 @@ export function SidebarSection({ selectedChannelId, title, testId, + threadOnlyUnreadChannelIds, unreadChannelCounts, unreadChannelIds, onHideDm, @@ -373,6 +409,7 @@ export function SidebarSection({ selectedChannelId: string | null; title: string; testId: string; + threadOnlyUnreadChannelIds?: ReadonlySet; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; onHideDm?: (channelId: string) => void; @@ -442,6 +479,9 @@ export function SidebarSection({ activeWorking={activeWorkingByChannelId?.get(channel.id)} dmParticipants={dmParticipantsByChannelId?.[channel.id]} hasUnread={unreadChannelIds.has(channel.id)} + hasThreadOnlyUnread={threadOnlyUnreadChannelIds?.has( + channel.id, + )} unreadCount={unreadChannelCounts.get(channel.id) ?? 0} isMuted={mutedChannelIds?.has(channel.id)} isActive={ diff --git a/desktop/src/shared/ui/UnreadPill.tsx b/desktop/src/shared/ui/UnreadPill.tsx index 153e614787..2638291673 100644 --- a/desktop/src/shared/ui/UnreadPill.tsx +++ b/desktop/src/shared/ui/UnreadPill.tsx @@ -15,12 +15,14 @@ export function UnreadPill({ onClick, testId, }: { - direction: "up" | "down"; + /** `none` renders a static pill without a direction arrow. */ + direction: "up" | "down" | "none"; label: string; onClick: () => void; testId: string; }) { - const Arrow = direction === "up" ? ArrowUp : ArrowDown; + const Arrow = + direction === "up" ? ArrowUp : direction === "down" ? ArrowDown : null; return ( ); diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 0dfdfb549a..bc5e0409b2 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -190,7 +190,10 @@ test("numeric badge increments for interested thread reply in inactive channel", { parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey }, ); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + // A plain thread reply (no mention, not a DM) renders the demoted + // thread-glyph badge instead of the solid numeric badge. + await expect(page.getByTestId("channel-thread-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); diff --git a/desktop/tests/e2e/channel-sort.spec.ts b/desktop/tests/e2e/channel-sort.spec.ts index c267a03b32..c1fe475886 100644 --- a/desktop/tests/e2e/channel-sort.spec.ts +++ b/desktop/tests/e2e/channel-sort.spec.ts @@ -38,6 +38,7 @@ function streamNames(page: Page) { .filter( (id) => !id.startsWith("channel-unread") && + !id.startsWith("channel-thread-unread") && !id.startsWith("channel-working") && !id.startsWith("channel-dm-count"), ) @@ -138,6 +139,7 @@ test.describe("per-group channel sort", () => { .filter( (id) => !id.startsWith("channel-unread") && + !id.startsWith("channel-thread-unread") && !id.startsWith("channel-working"), ) .map((id) => id.replace(/^channel-/, "")), diff --git a/desktop/tests/e2e/thread-unread.spec.ts b/desktop/tests/e2e/thread-unread.spec.ts index 586ca3dd12..3593979dc8 100644 --- a/desktop/tests/e2e/thread-unread.spec.ts +++ b/desktop/tests/e2e/thread-unread.spec.ts @@ -164,7 +164,20 @@ test.describe("thread unread indicator", () => { const badge = page.getByTestId("thread-unread-badge"); await expect(badge).toBeVisible(); - await expect(badge).toContainText("3"); + await expect(badge).toContainText("3 new"); + + // The parent row carries a left accent while its thread has unreads. + await expect(page.getByTestId("thread-unread-accent")).toBeVisible(); + + // With no top-level unreads, the floating pill advertises thread replies. + const threadPill = page.getByTestId("thread-unread-pill"); + await expect(threadPill).toBeVisible(); + await expect(threadPill).toContainText("3 new replies in threads"); + + // Clicking jumps to the parent row and opens its thread panel. + await threadPill.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(threadPill).toHaveCount(0); }); test("02-thread-new-divider", async ({ page }) => { @@ -748,10 +761,13 @@ test.describe("thread unread indicator", () => { await expect(page.getByTestId("chat-title")).toHaveText("general"); // The crux: leave general. The unopened thread reply should still keep a - // numeric channel sidebar badge until the thread itself is read. + // channel sidebar badge — rendered as the thread-glyph badge, since the + // only unread is a plain thread reply — until the thread itself is read. await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); - await expect(page.getByTestId("channel-unread-general")).toBeVisible(); + await expect( + page.getByTestId("channel-thread-unread-general"), + ).toBeVisible(); }); // Regression guard for the all-replies window: when the loaded window holds From 0b76a04bd8a004830c42a4507060cda5b4b3f34e Mon Sep 17 00:00:00 2001 From: David Hamilton Date: Thu, 30 Jul 2026 12:38:20 -0400 Subject: [PATCH 2/3] refine(desktop): clarify unread thread hierarchy Signed-off-by: David Hamilton --- desktop/src/features/messages/ui/MessageRow.tsx | 14 -------------- .../messages/ui/MessageThreadSummaryRow.tsx | 6 ++++-- .../features/messages/ui/TimelineMessageList.tsx | 8 +++++++- desktop/src/features/sidebar/ui/SidebarSection.tsx | 9 ++++++--- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 171dbfd404..42c12d6123 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -72,7 +72,6 @@ export const MessageRow = React.memo( huddleMemberPubkeysPending = false, actionBarPlacement = "floating", collapseDescendantsLabel, - hasUnreadThreadReplies = false, isFollowingThread, isContinuation = false, isUnread, @@ -101,9 +100,6 @@ export const MessageRow = React.memo( collapseDepthGuideActions?: ReadonlyArray; connectDescendants?: boolean; depthGuideDepths?: ReadonlyArray; - /** True when this message's thread has unread replies — renders a subtle - * left accent so the unread thread is visible in the channel scroll. */ - hasUnreadThreadReplies?: boolean; highlighted?: boolean; highlightDescendantRail?: boolean; highlightReplyConnector?: boolean; @@ -802,15 +798,6 @@ export const MessageRow = React.memo( data-testid="message-row" onAnimationEnd={handleEntranceAnimationEnd} > - {/* Real element, not a `before:` pseudo — `highlighted` already owns - the row's before: pseudo for the jump-highlight flash. */} - {hasUnreadThreadReplies ? ( - - ) : null} {isThreadReplyLayout ? ( <> {avatarGutterNode} @@ -865,7 +852,6 @@ export const MessageRow = React.memo( prev.collapseDescendantsLabel === next.collapseDescendantsLabel && prev.connectDescendants === next.connectDescendants && numberArrayEqual(prev.depthGuideDepths, next.depthGuideDepths) && - prev.hasUnreadThreadReplies === next.hasUnreadThreadReplies && prev.highlightDescendantRail === next.highlightDescendantRail && prev.highlighted === next.highlighted && prev.highlightReplyConnector === next.highlightReplyConnector && diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index 5b74f33526..1822d6d41c 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -95,9 +95,11 @@ export function MessageThreadSummaryRow({ THREAD_SUMMARY_SURFACE_AVATAR_INSET_REM, )})`; const replyLabel = summary.replyCount === 1 ? "reply" : "replies"; + const unreadLabel = + unreadCount != null && unreadCount > 0 ? `, ${unreadCount} unread` : ""; const summaryAriaLabel = summary.lastReplyAt - ? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatThreadSummaryLastReplyTime(summary.lastReplyAt)}` - : `View thread with ${summary.replyCount} ${replyLabel}`; + ? `View thread with ${summary.replyCount} ${replyLabel}${unreadLabel}, last reply ${formatThreadSummaryLastReplyTime(summary.lastReplyAt)}` + : `View thread with ${summary.replyCount} ${replyLabel}${unreadLabel}`; const guideDepths = depthGuideDepths ? [...depthGuideDepths] : Array.from({ length: Math.max(0, depth - 1) }, (_, index) => index + 1); diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index b78024054f..b838bf13ec 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -790,6 +790,13 @@ function MessageRowItem({ "-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6", )} > + {hasUnreadThreadReplies ? ( + + ) : null} - ); } From 9e8cedb69eb89eb37e2e768604bf423358c8e356 Mon Sep 17 00:00:00 2001 From: David Hamilton Date: Thu, 30 Jul 2026 13:53:18 -0400 Subject: [PATCH 3/3] test(desktop): add regenerable thread-unread design screenshots Signed-off-by: David Hamilton --- desktop/playwright.config.ts | 1 + .../e2e/thread-unread-screenshots.spec.ts | 189 ++++++++++++++++++ 2 files changed, 190 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 c2eec84a83..2d394d7f0f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -75,6 +75,7 @@ export default defineConfig({ "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", + "**/thread-unread-screenshots.spec.ts", "**/workspace-rail.spec.ts", "**/community-rail.spec.ts", "**/boot-splash.spec.ts", 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 0000000000..e29d86a9ae --- /dev/null +++ b/desktop/tests/e2e/thread-unread-screenshots.spec.ts @@ -0,0 +1,189 @@ +/** + * Screenshots documenting the thread-notification traceability design for + * PR #3754. Keeping them in a spec means the next design round regenerates + * the images from the shipped code instead of letting mocks drift. + * + * Run: pnpm build:e2e && pnpm exec playwright test --project=smoke \ + * tests/e2e/thread-unread-screenshots.spec.ts + * Output: test-results/thread-unread-screenshots/ + */ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/thread-unread-screenshots"; + +// The pubkey the mock bridge logs in as (mirrors `e2eBridge`'s self identity). +const SELF_PUBKEY = "deadbeef".repeat(8); + +// Unread thread replies must be dated strictly after the read frontier +// captured when the thread was last open (see thread-unread.spec.ts). +const UNREAD_OFFSET_SECONDS = 60; + +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); +} + +async function emitMockMessage( + page: import("@playwright/test").Page, + input: { + channelName: string; + content: string; + kind?: number; + parentEventId?: string; + pubkey?: string; + createdAt?: number; + mentionPubkeys?: string[]; + }, +): Promise<{ id: string }> { + const event = await page.evaluate( + (message) => + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (payload: typeof message) => { + id: string; + }; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.(message), + { pubkey: TEST_IDENTITIES.alice.pubkey, ...input }, + ); + if (!event) { + throw new Error("Mock message emitter is not installed"); + } + return event; +} + +test.describe("thread unread design screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("01 — sidebar signal grammar", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + for (const ch of ["random", "agents", "engineering", "alice-tyler"]) { + await waitForMockLiveSubscription(page, ch); + } + + // Ambient unread → dot. + await emitMockMessage(page, { + channelName: "agents", + content: "Nightly harness run finished clean.", + kind: 40002, + }); + // Mention → solid count. + await emitMockMessage(page, { + channelName: "random", + content: "@tyler can you review the navigation states?", + kind: 40002, + mentionPubkeys: [SELF_PUBKEY], + }); + // DM → solid count. + await emitMockMessage(page, { + channelName: "alice-tyler", + content: "Quick look before the review?", + }); + // Reply in a self-authored thread → thread-only badge. + const root = await emitMockMessage(page, { + channelName: "engineering", + content: "Proposal: make notification destinations traceable", + kind: 40002, + pubkey: SELF_PUBKEY, + }); + await emitMockMessage(page, { + channelName: "engineering", + content: "Replied in the thread with interaction notes.", + kind: 40002, + parentEventId: root.id, + }); + + await expect( + page.getByTestId("channel-thread-unread-engineering"), + ).toBeVisible(); + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible(); + await expect(page.getByTestId("channel-unread-dot-agents")).toBeVisible(); + await waitForAnimations(page); + + await page.screenshot({ path: `${SHOTS}/01-sidebar-signal-grammar.png` }); + await page.screenshot({ + clip: { height: 760, width: 272, x: 0, y: 0 }, + path: `${SHOTS}/02-sidebar-signal-grammar-detail.png`, + }); + }); + + test("02 — channel wayfinding and destination", 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"); + + // Establish a read frontier on the welcome thread, then leave. + await emitMockMessage(page, { + channelName: "general", + content: "Initial thread context", + createdAt: Math.floor(Date.now() / 1000) - 10, + parentEventId: "mock-general-welcome", + }); + await page.getByTestId("message-thread-summary").first().click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await page.getByTestId("auxiliary-panel-close").click(); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // Three unread replies land in the thread while the user is away. + const base = Math.floor(Date.now() / 1000) + UNREAD_OFFSET_SECONDS; + const replies = [ + "The signal hierarchy is the main problem — channel outranks Inbox.", + "Added before/after states and a thread-specific destination cue.", + "Deep-linking already works, so this is presentation, not routing.", + ]; + for (const [index, content] of replies.entries()) { + await emitMockMessage(page, { + channelName: "general", + content, + createdAt: base + index, + parentEventId: "mock-general-welcome", + }); + } + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("thread-unread-badge")).toContainText( + "3 new", + ); + await expect(page.getByTestId("thread-unread-accent")).toBeVisible(); + const threadPill = page.getByTestId("thread-unread-pill"); + await expect(threadPill).toContainText("3 new replies in threads"); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/03-channel-thread-cues.png` }); + + // The pill jumps to the parent and opens the thread on the New divider. + await threadPill.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(page.getByTestId("message-unread-divider")).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/04-thread-destination.png` }); + }); +});