From b1f13f65bd22451fe1aa8039e66362482190ba08 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 20 May 2026 14:45:29 -0400 Subject: [PATCH 1/2] fix(desktop): project home read-state from the shared NIP-RS marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a channel in-thread now clears the Home panel indicators for items in that channel — both the row blue dot / bold styling and the sidebar Home badge. Before, Home maintained its own local "done" set (`sprout-home-feed-done.v1`) and "seen" set (`sprout-home-feed-seen.v1`) that were only advanced by interacting with Home itself, so a message read in-channel stayed unread in Home indefinitely. Changes: * `useUnreadChannels` now also returns `getEffectiveTimestamp` and `readStateVersion` from the underlying `ReadStateManager`, so other surfaces can project per-item read-state off the same NIP-RS marker without instantiating a second manager. * `AppShellContext` exposes those two fields as `getChannelReadAt` and `readStateVersion` for any descendant. * New `useHomeInboxReadState` hook in `features/home/` composes the projection: channel-backed items are read iff `latestActivityAt <= getChannelReadAt(channelId)`; items without a channel (reminders etc.) fall back to the local `useFeedItemState` done-set. Mark/undo for channel-backed items is routed through `markChannelRead`/`markChannelUnread`, keeping the storage hook policy-free. * `useHomeFeedNotificationState` gains the same NIP-RS-aware predicate for the sidebar Home badge (using `FeedItem.createdAt`, not `latestActivityAt`, since the badge works over raw feed items). * To preserve a single `ReadStateManager`, the badge state computation is lifted out of `useHomeFeedNotifications` and into `AppShell`, after `useUnreadChannels` has mounted. Behavioural note: marking an older channel-backed Home item "unread" rolls the NIP-RS read marker back to that item's timestamp for the whole channel, so newer events in that channel become unread too. That matches NIP-RS's channel-level granularity; it isn't per-message granular. No data migration: stale channel-id entries in the old localStorage done-set are ignored by the new projection and age out naturally. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/scripts/check-file-sizes.mjs | 3 +- desktop/src/app/AppShell.tsx | 65 +++++++---- desktop/src/app/AppShellContext.tsx | 9 ++ .../features/channels/useUnreadChannels.ts | 6 + desktop/src/features/home/ui/HomeView.tsx | 33 ++++-- .../features/home/useHomeInboxReadState.ts | 109 ++++++++++++++++++ desktop/src/features/notifications/hooks.ts | 62 ++++++---- 7 files changed, 234 insertions(+), 53 deletions(-) create mode 100644 desktop/src/features/home/useHomeInboxReadState.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c5ff1559f..786ee858b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -33,12 +33,13 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests - ["src/app/AppShell.tsx", 800], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + ["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers ["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification ["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state + ["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState) ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates ["src/features/messages/ui/MessageComposer.tsx", 760], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) ["src/features/settings/ui/SettingsView.tsx", 600], diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4a16a7cb5..837191eb9 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -20,7 +20,10 @@ import { useOpenDmMutation, } from "@/features/channels/hooks"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; -import { useHomeFeedNotifications } from "@/features/notifications/hooks"; +import { + useHomeFeedNotifications, + useHomeFeedNotificationState, +} from "@/features/notifications/hooks"; import { listenForDesktopNotificationActions, revealDesktopAppWindow, @@ -201,11 +204,8 @@ export function AppShell() { deferredPubkey ? [deferredPubkey] : [], ); const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey); - const { homeBadgeCount, homeFeedQuery, notificationSettings } = - useHomeFeedNotifications( - identityQuery.data?.pubkey, - selectedView === "home", - ); + const { feedProfilesQuery, homeFeedQuery, notificationSettings } = + useHomeFeedNotifications(identityQuery.data?.pubkey); const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); @@ -264,21 +264,42 @@ export function AppShell() { [channels, selectedChannelId], ); - const { markChannelRead, markChannelUnread, unreadChannelIds } = - useUnreadChannels( - channels, - 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, - onDmMessage: handleDmNotification, - onLiveMention: refetchHomeFeedOnLiveMention, - }, - ); + const { + markChannelRead, + markChannelUnread, + unreadChannelIds, + getEffectiveTimestamp: getChannelReadAt, + readStateVersion, + } = useUnreadChannels( + channels, + 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, + onDmMessage: handleDmNotification, + onLiveMention: refetchHomeFeedOnLiveMention, + }, + ); + + // 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 + // feed items contribute to the badge iff strictly newer than that + // channel's read marker; non-channel items keep their seen-set fallback. + const homeBadgeCount = useHomeFeedNotificationState( + homeFeedQuery.data, + identityQuery.data?.pubkey, + notificationSettings.settings, + notificationSettings.setDesktopEnabled, + selectedView === "home", + getChannelReadAt, + readStateVersion, + feedProfilesQuery.data?.profiles, + ); const createChannelMutation = useCreateChannelMutation(); const createForumMutation = useCreateChannelMutation(); @@ -565,6 +586,8 @@ export function AppShell() { openChannelManagement: () => { setIsChannelManagementOpen(true); }, + getChannelReadAt, + readStateVersion, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 2f483dac8..533e50a3f 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -10,12 +10,21 @@ type AppShellContextValue = { lastMessageAt: string | null | undefined, ) => void; openChannelManagement: () => void; + // NIP-RS read marker for a channel as a unix-seconds timestamp, or null + // 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; + // Bump-counter that invalidates whenever the read marker changes. Include + // in memo deps that consume getChannelReadAt. + readStateVersion: number; }; const AppShellContext = React.createContext({ markChannelRead: () => {}, markChannelUnread: () => {}, openChannelManagement: () => {}, + getChannelReadAt: () => null, + readStateVersion: 0, }); export function AppShellProvider({ diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a6df14186..568e650ba 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -318,5 +318,11 @@ export function useUnreadChannels( unreadChannelIds, markChannelRead, markChannelUnread, + // Exposed so other surfaces (e.g. Home) can project per-item read state + // off the same NIP-RS read marker without instantiating a second + // ReadStateManager. readStateVersion is the invalidation signal callers + // should include in memo deps. + getEffectiveTimestamp, + readStateVersion, }; } diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 5089b8805..0933d453e 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { RefreshCcw } from "lucide-react"; +import { useAppShell } from "@/app/AppShellContext"; import { useChannelsQuery } from "@/features/channels/hooks"; import { type InboxFilter, @@ -10,6 +11,7 @@ import { formatInboxFullTimestamp, } from "@/features/home/lib/inbox"; import { useFeedItemState } from "@/features/home/useFeedItemState"; +import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; import { useResizableInboxListWidth } from "@/features/home/useResizableInboxListWidth"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; @@ -134,6 +136,12 @@ export function HomeView({ inboxListWidthPx, } = useResizableInboxListWidth(); const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); + const { + getChannelReadAt, + markChannelRead, + markChannelUnread, + readStateVersion, + } = useAppShell(); const feedItems = React.useMemo( () => feed @@ -196,6 +204,17 @@ export function HomeView({ }), [currentPubkey, feed, feedProfiles], ); + const { effectiveDoneSet, markItemRead, markItemUnread } = + useHomeInboxReadState({ + items: inboxItems, + getChannelReadAt, + readStateVersion, + localDoneSet: doneSet, + markChannelRead, + markChannelUnread, + markDoneLocal: markDone, + undoDoneLocal: undoDone, + }); const filteredItems = React.useMemo(() => { return inboxItems.filter((item) => matchesInboxFilter(item, filter)); }, [filter, inboxItems]); @@ -277,14 +296,14 @@ export function HomeView({ const handleToggleDone = React.useCallback( (itemId: string) => { - if (doneSet.has(itemId)) { - undoDone(itemId); + if (effectiveDoneSet.has(itemId)) { + markItemUnread(itemId); return; } - markDone(itemId); + markItemRead(itemId); }, - [doneSet, markDone, undoDone], + [effectiveDoneSet, markItemRead, markItemUnread], ); if (isLoading && !feed) { @@ -345,13 +364,13 @@ export function HomeView({ } > { setSelectedItemId(itemId); - markDone(itemId); + markItemRead(itemId); }} selectedId={selectedItemId} /> @@ -383,7 +402,7 @@ export function HomeView({ )} canReply={canReply} disabledReplyReason={disabledReplyReason} - isDone={selectedItem ? doneSet.has(selectedItem.id) : false} + isDone={selectedItem ? effectiveDoneSet.has(selectedItem.id) : false} isDeletingMessage={isDeletingMessage} isSendingReply={isSendingReply} isThreadContextLoading={threadContext.isLoading} diff --git a/desktop/src/features/home/useHomeInboxReadState.ts b/desktop/src/features/home/useHomeInboxReadState.ts new file mode 100644 index 000000000..a682a43f5 --- /dev/null +++ b/desktop/src/features/home/useHomeInboxReadState.ts @@ -0,0 +1,109 @@ +import * as React from "react"; + +import type { InboxItem } from "@/features/home/lib/inbox"; + +type UseHomeInboxReadStateOptions = { + /** Inbox items to project read-state across. */ + items: InboxItem[]; + /** NIP-RS read marker resolver for channel-backed items (unix seconds, or null when unknown). */ + getChannelReadAt: (channelId: string) => number | null; + /** Invalidation signal for the channel-marker projection. */ + readStateVersion: number; + /** Local fallback "done" set (used only for items with no channelId). */ + localDoneSet: ReadonlySet; + /** Mark a channel read up to the given ISO timestamp (NIP-RS). */ + markChannelRead: ( + channelId: string, + readAt: string | null | undefined, + ) => void; + /** Roll the NIP-RS read marker back to the given ISO timestamp. */ + markChannelUnread: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + /** Local fallback: mark a non-channel item done. */ + markDoneLocal: (id: string) => void; + /** Local fallback: undo a non-channel item done. */ + undoDoneLocal: (id: string) => void; +}; + +/** + * Projects Home inbox read-state from the shared NIP-RS read marker, with + * the local `useFeedItemState` done-set as a fallback for items that don't + * belong to a channel (reminders etc.). + * + * "Mark as read/unread" actions on channel-backed items are routed through + * `markChannelRead`/`markChannelUnread` so the sidebar, home badge, and any + * other surfaces consuming the same ReadStateManager stay in lockstep. + * Caveat: marking an older item unread rolls the *entire* channel marker + * back to that item, so newer events in that channel become unread too — + * that matches NIP-RS's channel-level granularity. + */ +export function useHomeInboxReadState({ + items, + getChannelReadAt, + readStateVersion, + localDoneSet, + markChannelRead, + markChannelUnread, + markDoneLocal, + undoDoneLocal, +}: UseHomeInboxReadStateOptions) { + const itemById = React.useMemo( + () => new Map(items.map((item) => [item.id, item])), + [items], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt + const effectiveDoneSet = React.useMemo>(() => { + const result = new Set(); + for (const item of items) { + const channelId = item.item.channelId; + if (channelId) { + const readAt = getChannelReadAt(channelId); + if (readAt !== null && item.latestActivityAt <= readAt) { + result.add(item.id); + } + continue; + } + if (localDoneSet.has(item.id)) { + result.add(item.id); + } + } + return result; + }, [getChannelReadAt, items, localDoneSet, readStateVersion]); + + const markItemRead = React.useCallback( + (itemId: string) => { + const item = itemById.get(itemId); + const channelId = item?.item.channelId ?? null; + if (item && channelId) { + markChannelRead( + channelId, + new Date(item.latestActivityAt * 1_000).toISOString(), + ); + return; + } + markDoneLocal(itemId); + }, + [itemById, markChannelRead, markDoneLocal], + ); + + const markItemUnread = React.useCallback( + (itemId: string) => { + const item = itemById.get(itemId); + const channelId = item?.item.channelId ?? null; + if (item && channelId) { + markChannelUnread( + channelId, + new Date(item.latestActivityAt * 1_000).toISOString(), + ); + return; + } + undoDoneLocal(itemId); + }, + [itemById, markChannelUnread, undoDoneLocal], + ); + + return { effectiveDoneSet, markItemRead, markItemUnread }; +} diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index f4c9bac27..8f006c6d1 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -259,6 +259,15 @@ export function useHomeFeedNotificationState( settings: NotificationSettings, setDesktopEnabled: (enabled: boolean) => Promise, isHomeActive: boolean, + // NIP-RS read marker lookup, shared with the sidebar via AppShell. When + // provided, channel-backed feed items are treated as read iff their + // createdAt is at-or-below the channel's read marker; the local + // seen-set is reserved for items with no channel context. Pass + // `() => null` to keep the legacy local-only behaviour. + getChannelReadAt: (channelId: string) => number | null, + // Invalidation signal for the channel-marker projection; bump triggers + // recompute. Pass 0 to opt out. + readStateVersion: number, profiles?: UserProfileLookup, ) { useFeedDesktopNotifications( @@ -272,15 +281,14 @@ export function useHomeFeedNotificationState( const [seenFeedIds, setSeenFeedIds] = React.useState(() => readStoredSeenFeedIds(normalizedPubkey), ); - const currentFeedIds = React.useMemo( - () => - feed - ? [...feed.feed.mentions, ...feed.feed.needsAction].map( - (item) => item.id, - ) - : [], + const currentFeedItems = React.useMemo( + () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), [feed], ); + const currentFeedIds = React.useMemo( + () => currentFeedItems.map((item) => item.id), + [currentFeedItems], + ); React.useEffect(() => { setSeenFeedIds(readStoredSeenFeedIds(normalizedPubkey)); @@ -303,24 +311,39 @@ export function useHomeFeedNotificationState( markCurrentFeedSeen(); }, [currentFeedIds, isHomeActive, normalizedPubkey]); + // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt return React.useMemo(() => { if (!settings.homeBadgeEnabled || isHomeActive) { return 0; } - if (currentFeedIds.length === 0) { + if (currentFeedItems.length === 0) { return 0; } const seenFeedIdSet = new Set(seenFeedIds); - return currentFeedIds.filter((id) => !seenFeedIdSet.has(id)).length; - }, [currentFeedIds, isHomeActive, seenFeedIds, settings.homeBadgeEnabled]); + return currentFeedItems.filter((item) => { + if (item.channelId) { + // Channel-backed items: unread iff strictly newer than the NIP-RS + // read marker. No fallback to seenFeedIds — reading in-channel is + // the authoritative signal. + const readAt = getChannelReadAt(item.channelId); + return readAt === null || item.createdAt > readAt; + } + // No channel context: fall back to the local seen-set. + return !seenFeedIdSet.has(item.id); + }).length; + }, [ + currentFeedItems, + getChannelReadAt, + isHomeActive, + readStateVersion, + seenFeedIds, + settings.homeBadgeEnabled, + ]); } -export function useHomeFeedNotifications( - pubkey: string | undefined, - isHomeActive: boolean, -) { +export function useHomeFeedNotifications(pubkey: string | undefined) { const notificationSettings = useNotificationSettings(pubkey); const homeFeedQuery = useHomeFeedQuery(); const refetchHomeFeedForE2e = React.useEffectEvent(() => { @@ -364,17 +387,8 @@ export function useHomeFeedNotifications( { enabled: feedItems.length > 0 }, ); - const homeBadgeCount = useHomeFeedNotificationState( - homeFeedQuery.data, - pubkey, - notificationSettings.settings, - notificationSettings.setDesktopEnabled, - isHomeActive, - feedProfilesQuery.data?.profiles, - ); - return { - homeBadgeCount, + feedProfilesQuery, homeFeedQuery, notificationSettings, }; From 8f7142841040b092e7107b6a6f4f9ddeb9663676 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 20 May 2026 16:13:46 -0400 Subject: [PATCH 2/2] fix(desktop): preserve seen-set fallback when no NIP-RS marker exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Home badge predicate I added in b1f13f6 treated `getChannelReadAt` returning `null` as "unread". That's correct for a freshly-arrived event in a channel the user has never opened — but it broke two cases: 1. `profile.spec.ts:69` (mock mode, no relay client): every channel-backed feed item appears unread because the read-state hooks no-op. The test pushed one mock mention and saw count=2 (the preseeded `mock-feed-mention` plus the new push), expecting 1. 2. `integration.spec.ts:246` (live mention into the channel the user is currently reading): the in-channel marker auto-advances past the new mention, so the predicate correctly says "read". The test expected the badge to tick to 1 — that was the *old* design's behavior (driven by a local seen-set that didn't know about in-channel reads). The new design's behavior is what Tyler asked for: if you're reading the channel, the home badge shouldn't light up for messages arriving in it. Fixes: - `useHomeFeedNotificationState`: only consult the NIP-RS marker when it's non-null. When the channel has no marker yet (cold start, mock mode), fall back to the legacy `!seenFeedIdSet.has(id)` predicate so a freshly-seen feed item doesn't keep tripping the badge forever. - `integration.spec.ts:246`: replace the `sidebar-home-count = "1"` assertion (which embodied the old design) with an assertion that the refetched home inbox contains the mention text after navigating Home. That preserves the test's actual purpose ("live mentions refetch the home feed without waiting for polling") under the new contract. The sibling test for forum mentions (mention into a channel the user is NOT viewing) is unaffected and still asserts the badge ticks to 1 — that case goes through the seen-set fallback path. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/src/features/notifications/hooks.ts | 12 +++++++----- desktop/tests/e2e/integration.spec.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 8f006c6d1..9e59f2397 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -324,13 +324,15 @@ export function useHomeFeedNotificationState( const seenFeedIdSet = new Set(seenFeedIds); return currentFeedItems.filter((item) => { if (item.channelId) { - // Channel-backed items: unread iff strictly newer than the NIP-RS - // read marker. No fallback to seenFeedIds — reading in-channel is - // the authoritative signal. + // Channel-backed items: trust the NIP-RS marker when we have one. + // If the channel has no marker yet (cold start, mock mode without a + // relay client), fall back to the local seen-set so a freshly-seen + // feed item doesn't keep tripping the badge forever. const readAt = getChannelReadAt(item.channelId); - return readAt === null || item.createdAt > readAt; + if (readAt !== null) { + return item.createdAt > readAt; + } } - // No channel context: fall back to the local seen-set. return !seenFeedIdSet.has(item.id); }).length; }, [ diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index 29c9d00ed..d79af80d8 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -275,9 +275,6 @@ test("live mentions refetch the home feed without waiting for polling", async ({ await expect(targetPage.getByTestId("message-timeline")).toContainText( message, ); - await expect(targetPage.getByTestId("sidebar-home-count")).toHaveText("1", { - timeout: relayDeliveryTimeoutMs, - }); await expect .poll(() => getLoggedNotificationCount(targetPage), { @@ -294,8 +291,17 @@ test("live mentions refetch the home feed without waiting for polling", async ({ }, ]); + // The home feed should have been refetched live (the original purpose + // of this test). The home badge stays at 0 while the user is actively + // reading #general — reading in-channel advances the NIP-RS marker past + // the new mention — so the assertion that the refetch happened is the + // inbox-list content, not the badge. await targetPage.getByRole("button", { name: "Home" }).click(); await expect(targetPage.getByTestId("chat-title")).toHaveText("Home"); + await expect(targetPage.getByTestId("home-inbox-list")).toContainText( + message, + { timeout: relayDeliveryTimeoutMs }, + ); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect .poll(() => getLoggedNotificationCount(targetPage), {