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..9e59f2397 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,41 @@ 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: 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); + if (readAt !== null) { + return item.createdAt > readAt; + } + } + 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 +389,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, }; 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), {