diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eb5ab5a95d8..d646e3368af 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -377,6 +377,7 @@ export function AppShell() { unreadChannelNotificationCount, getEffectiveTimestamp: getChannelReadAt, getOwnTimestamp: getOwnReadAt, + isReadStateReady, readStateVersion, setContextParentResolver, participatedRootIds, @@ -726,6 +727,7 @@ export function AppShell() { getChannelActivityItemReadAt, markMessageRead, readStateVersion, + isReadStateReady, setContextParentResolver, followThread: handleFollowThread, unfollowThread: handleUnfollowThread, diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index b909436178b..2a84b807f27 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -49,6 +49,9 @@ type AppShellContextValue = { // Bump-counter that invalidates whenever the read marker changes. Include // in memo deps that consume getChannelReadAt. readStateVersion: number; + // False until NIP-RS has finished its first hydrate. Inbox must not treat + // a missing marker as unread during that window. + isReadStateReady: boolean; // Inject the thread→channel parent resolver derived from the event graph // (NIP-RS hierarchical frontier). Set by the active channel surface. setContextParentResolver: (resolver: ContextParentResolver | null) => void; @@ -97,6 +100,7 @@ const AppShellContext = React.createContext({ getChannelActivityItemReadAt: () => null, markMessageRead: () => {}, readStateVersion: 0, + isReadStateReady: false, setContextParentResolver: () => {}, followThread: () => {}, unfollowThread: () => {}, diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index ceab544d8cb..fd3476cb50b 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -980,6 +980,7 @@ export function useUnreadChannels( // should include in memo deps. getEffectiveTimestamp, getOwnTimestamp, + isReadStateReady, readStateVersion, setContextParentResolver, participatedRootIds, diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index 2cd6424ab5a..19b2cf92a84 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -9,8 +9,10 @@ import { getReactionTargetId, hasInboxThreadContext, isInboxThreadContextEvent, + filterVisibleInboxItems, matchesInboxAllView, matchesInboxFilter, + matchesUnreadOnlyVisibility, toInboxContextMessage, toTimelineMessage, } from "./inboxViewHelpers.ts"; @@ -65,6 +67,56 @@ test("hasInboxThreadContext keeps standalone and broadcast activity unthreaded", ); }); +test("unread-only keeps a done row only for an explicit URL selection", () => { + const item = { conversationId: "thread-1", id: "event-1" }; + const doneSet = new Set(["event-1"]); + + assert.equal( + matchesUnreadOnlyVisibility(item, { + doneSet, + selectedConversationId: "thread-1", + unreadOnly: true, + urlSelectedItemId: null, + }), + false, + ); + assert.equal( + matchesUnreadOnlyVisibility(item, { + doneSet, + selectedConversationId: "thread-1", + unreadOnly: true, + urlSelectedItemId: "event-1", + }), + true, + ); +}); + +test("filterVisibleInboxItems drops auto-selected done rows in unread-only", () => { + const unread = { + categories: ["mention"], + conversationId: "unread-thread", + id: "unread-event", + item: { kind: 9 }, + }; + const done = { + categories: ["mention"], + conversationId: "done-thread", + id: "done-event", + item: { kind: 9 }, + }; + + assert.deepEqual( + filterVisibleInboxItems([unread, done], { + doneSet: new Set(["done-event"]), + filter: "all", + selectedConversationId: "done-thread", + unreadOnly: true, + urlSelectedItemId: null, + }).map((item) => item.id), + ["unread-event"], + ); +}); + // --- matchesInboxFilter --- test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => { diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index d1bffd1899c..764c534494e 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -29,6 +29,46 @@ export function filterInboxItems(items: InboxItem[]) { return items.filter((item) => item.item.kind !== KIND_REMINDER); } +/** + * Unread-only keeps a done row only when the user explicitly selected it + * (`?item=`). Auto-select must not pin a conversation after it becomes done, + * or Inbox boot leaves one stale row until the view remounts. + */ +export function matchesUnreadOnlyVisibility( + item: { conversationId: string; id: string }, + options: { + doneSet: ReadonlySet; + selectedConversationId: string | null; + unreadOnly: boolean; + urlSelectedItemId: string | null; + }, +): boolean { + if (!options.unreadOnly) return true; + if (!options.doneSet.has(item.id)) return true; + return ( + Boolean(options.urlSelectedItemId) && + item.conversationId === options.selectedConversationId + ); +} + +export function filterVisibleInboxItems( + items: InboxItem[], + options: { + doneSet: ReadonlySet; + filter: InboxFilter; + ownedAgentPubkeys?: ReadonlySet; + selectedConversationId: string | null; + unreadOnly: boolean; + urlSelectedItemId: string | null; + }, +): InboxItem[] { + return items.filter( + (item) => + matchesInboxFilter(item, options.filter, options.ownedAgentPubkeys) && + matchesUnreadOnlyVisibility(item, options), + ); +} + export function hasInboxThreadContext( item: Pick, contextMessages: readonly Pick[] = [], diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0a16f27c4d0..0affb55ccaf 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -19,7 +19,7 @@ import { useInboxEditMessage } from "@/features/home/useInboxEditMessage"; import { useOwnedAgentPubkeys } from "@/features/home/useOwnedAgentPubkeys"; import { filterInboxItems, - matchesInboxFilter, + filterVisibleInboxItems, } from "@/features/home/lib/inboxViewHelpers"; import { resolveInboxFilterSelection } from "@/features/home/lib/inboxSelection"; import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; @@ -244,6 +244,7 @@ export function HomeView({ markThreadRead, recordThreadInteraction, readStateVersion, + isReadStateReady, } = useAppShell(); const { doneSet, markDone, markUnread, undoDone, undoUnread, unreadSet } = feedItemState; @@ -387,6 +388,7 @@ export function HomeView({ getThreadReadAt, getMessageReadAt, readStateVersion, + isReadStateReady, localDoneSet: doneSet, localUnreadSet: unreadSet, clearChannelUnreadSource, @@ -418,25 +420,27 @@ export function HomeView({ const selectedConversationId = selectedItemFromAll?.conversationId ?? latchedConversationId; - const filteredItems = React.useMemo(() => { - return inboxItems.filter( - (item) => - matchesInboxFilter(item, filter, ownedAgentPubkeys) && - (!unreadOnly || - !effectiveDoneSet.has(item.id) || - item.conversationId === selectedConversationId), - ); - }, [ - effectiveDoneSet, - filter, - inboxItems, - ownedAgentPubkeys, - selectedConversationId, - unreadOnly, - ]); - // A filter change may only retain detail for a conversation that remains - // visible. The filter handler selects the next valid row in the same update, - // so the detail pane never renders a stale conversation between states. + const filteredItems = React.useMemo( + () => + filterVisibleInboxItems(inboxItems, { + doneSet: effectiveDoneSet, + filter, + ownedAgentPubkeys, + selectedConversationId, + unreadOnly, + urlSelectedItemId, + }), + [ + effectiveDoneSet, + filter, + inboxItems, + ownedAgentPubkeys, + selectedConversationId, + unreadOnly, + urlSelectedItemId, + ], + ); + // Filter changes retain detail only for a conversation that stays visible. const selectedItem = React.useMemo(() => { if (!selectedEventId) return null; const fromFiltered = findInboxItemByEventId(filteredItems, selectedEventId); @@ -534,13 +538,14 @@ export function HomeView({ const handleFilterChange = React.useCallback( (nextFilter: InboxFilter) => { - const nextItems = inboxItems.filter( - (item) => - matchesInboxFilter(item, nextFilter, ownedAgentPubkeys) && - (!unreadOnly || - !effectiveDoneSet.has(item.id) || - item.conversationId === selectedConversationId), - ); + const nextItems = filterVisibleInboxItems(inboxItems, { + doneSet: effectiveDoneSet, + filter: nextFilter, + ownedAgentPubkeys, + selectedConversationId, + unreadOnly, + urlSelectedItemId, + }); const selection = resolveInboxFilterSelection({ isNarrow: isNarrowHomeViewport, items: nextItems, @@ -577,6 +582,7 @@ export function HomeView({ setSelectedDraftKey, setSelectedReminderId, unreadOnly, + urlSelectedItemId, ], ); diff --git a/desktop/src/features/home/useHomeInboxReadState.test.mjs b/desktop/src/features/home/useHomeInboxReadState.test.mjs index 77caecad0f3..0172b60e7c0 100644 --- a/desktop/src/features/home/useHomeInboxReadState.test.mjs +++ b/desktop/src/features/home/useHomeInboxReadState.test.mjs @@ -6,6 +6,7 @@ import { getGroupedInboxItemIds, hasRemainingChannelUnreadOverride, hasGroupedUnreadOverride, + projectInboxDoneSet, resolveInboxItemReadAt, } from "./useHomeInboxReadState.ts"; @@ -18,7 +19,9 @@ function feedItem(overrides) { pubkey: "author", content: "hello", createdAt: overrides.createdAt, - channelId: overrides.channelId ?? CHANNEL_ID, + channelId: Object.hasOwn(overrides, "channelId") + ? overrides.channelId + : CHANNEL_ID, channelName: "buzz-bugs", tags: overrides.tags ?? [["h", CHANNEL_ID]], category: overrides.category ?? "activity", @@ -30,6 +33,7 @@ function inboxItem(groupItems, item = groupItems.at(-1)) { id: item.id, item, groupItems, + latestActivityAt: Math.max(...groupItems.map((entry) => entry.createdAt)), }; } @@ -254,3 +258,126 @@ test("parent-only replies use their parent as the thread read context", () => { ); assert.equal(resolvedRootId, "parent-event"); }); + +test("unknown channel markers before NIP-RS hydrate are done, not unread", () => { + const channelRow = inboxItem([ + feedItem({ + id: "channel-event", + createdAt: 200, + }), + ]); + const threadRow = inboxItem([ + feedItem({ + id: "reply-event", + createdAt: 200, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }), + ]); + + const done = projectInboxDoneSet([channelRow, threadRow], { + getChannelReadAt: () => null, + getMessageReadAt: () => null, + getThreadReadAt: () => null, + isReadStateReady: false, + localDoneSet: new Set(), + localUnreadSet: new Set(), + }); + + assert.equal(done.has("channel-event"), true); + assert.equal(done.has("reply-event"), true); +}); + +test("unknown channel markers after NIP-RS hydrate stay unread", () => { + const channelRow = inboxItem([ + feedItem({ + id: "channel-event", + createdAt: 200, + }), + ]); + + const done = projectInboxDoneSet([channelRow], { + getChannelReadAt: () => null, + getMessageReadAt: () => null, + getThreadReadAt: () => null, + isReadStateReady: true, + localDoneSet: new Set(), + localUnreadSet: new Set(), + }); + + assert.equal(done.has("channel-event"), false); +}); + +test("known unread markers stay unread during NIP-RS hydrate", () => { + const channelRow = inboxItem([ + feedItem({ + id: "channel-event", + createdAt: 200, + }), + ]); + + const done = projectInboxDoneSet([channelRow], { + getChannelReadAt: () => 100, + getMessageReadAt: () => null, + getThreadReadAt: () => null, + isReadStateReady: false, + localDoneSet: new Set(), + localUnreadSet: new Set(), + }); + + assert.equal(done.has("channel-event"), false); +}); + +test("local unread override still wins during NIP-RS hydrate", () => { + const channelRow = inboxItem([ + feedItem({ + id: "channel-event", + createdAt: 200, + }), + ]); + + const done = projectInboxDoneSet([channelRow], { + getChannelReadAt: () => null, + getMessageReadAt: () => null, + getThreadReadAt: () => null, + isReadStateReady: false, + localDoneSet: new Set(), + localUnreadSet: new Set(["channel-event"]), + }); + + assert.equal(done.has("channel-event"), false); +}); + +test("non-channel rows still use the local done-set during NIP-RS hydrate", () => { + const reminderRow = inboxItem([ + feedItem({ + id: "reminder-event", + channelId: null, + createdAt: 200, + tags: [], + }), + ]); + + const unread = projectInboxDoneSet([reminderRow], { + getChannelReadAt: () => null, + getMessageReadAt: () => null, + getThreadReadAt: () => null, + isReadStateReady: false, + localDoneSet: new Set(), + localUnreadSet: new Set(), + }); + const done = projectInboxDoneSet([reminderRow], { + getChannelReadAt: () => null, + getMessageReadAt: () => null, + getThreadReadAt: () => null, + isReadStateReady: false, + localDoneSet: new Set(["reminder-event"]), + localUnreadSet: new Set(), + }); + + assert.equal(unread.has("reminder-event"), false); + assert.equal(done.has("reminder-event"), true); +}); diff --git a/desktop/src/features/home/useHomeInboxReadState.ts b/desktop/src/features/home/useHomeInboxReadState.ts index 24d536e7301..95b72436e0e 100644 --- a/desktop/src/features/home/useHomeInboxReadState.ts +++ b/desktop/src/features/home/useHomeInboxReadState.ts @@ -18,6 +18,8 @@ type UseHomeInboxReadStateOptions = { getMessageReadAt?: (messageId: string) => number | null; /** Invalidation signal for the channel-marker projection. */ readStateVersion: number; + /** False until NIP-RS has finished its first hydrate. */ + isReadStateReady?: boolean; /** Local fallback "done" set (used only for items with no channelId). */ localDoneSet: ReadonlySet; /** Per-item local unread override for inbox rows. */ @@ -133,6 +135,63 @@ export function resolveInboxItemReadAt( return channelId ? options.getChannelReadAt(channelId) : null; } +export function isInboxItemChannelBacked(item: InboxItem): boolean { + return getInboxThreadRootId(item) !== null || Boolean(item.item.channelId); +} + +/** + * Projects which inbox rows are "done". Channel/thread rows follow NIP-RS. + * Missing markers during the first hydrate are unknown, not unread — otherwise + * unread-only Inbox paints a fake pile until ReadStateManager finishes (~7s). + * Local done-set is only a fallback for rows with no channel (reminders). + */ +export function projectInboxDoneSet( + items: InboxItem[], + options: { + getChannelReadAt: (channelId: string) => number | null; + getMessageReadAt?: (messageId: string) => number | null; + getThreadReadAt: ( + rootId: string, + channelId?: string | null, + ) => number | null; + isReadStateReady?: boolean; + localDoneSet: ReadonlySet; + localUnreadSet: ReadonlySet; + }, +): Set { + const result = new Set(); + const isReadStateReady = options.isReadStateReady ?? true; + for (const item of items) { + if (hasGroupedUnreadOverride(item, options.localUnreadSet)) { + continue; + } + + const isChannelBacked = isInboxItemChannelBacked(item); + const readAt = resolveInboxItemReadAt(item, options); + if (readAt !== null) { + if (item.latestActivityAt <= readAt) { + result.add(item.id); + } + continue; + } + + // Unknown marker before hydrate completes: do not treat as unread. + if (!isReadStateReady && isChannelBacked) { + result.add(item.id); + continue; + } + + if (isChannelBacked) { + continue; + } + + if (options.localDoneSet.has(item.id)) { + result.add(item.id); + } + } + return result; +} + /** * 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 @@ -150,6 +209,7 @@ export function useHomeInboxReadState({ getThreadReadAt, getMessageReadAt, readStateVersion, + isReadStateReady = true, localDoneSet, localUnreadSet = EMPTY_ITEM_SET, markChannelRead, @@ -168,44 +228,27 @@ export function useHomeInboxReadState({ ); // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt - const effectiveDoneSet = React.useMemo>(() => { - const result = new Set(); - for (const item of items) { - if (hasGroupedUnreadOverride(item, localUnreadSet)) { - continue; - } - - const threadRootId = getInboxThreadRootId(item); - const readAt = resolveInboxItemReadAt(item, { + const effectiveDoneSet = React.useMemo>( + () => + projectInboxDoneSet(items, { getChannelReadAt, getMessageReadAt, getThreadReadAt, - }); - if (readAt !== null) { - if (item.latestActivityAt <= readAt) { - result.add(item.id); - } - continue; - } - - if (threadRootId !== null || item.item.channelId) { - continue; - } - - if (localDoneSet.has(item.id)) { - result.add(item.id); - } - } - return result; - }, [ - getChannelReadAt, - getThreadReadAt, - getMessageReadAt, - items, - localDoneSet, - localUnreadSet, - readStateVersion, - ]); + isReadStateReady, + localDoneSet, + localUnreadSet, + }), + [ + getChannelReadAt, + getThreadReadAt, + getMessageReadAt, + isReadStateReady, + items, + localDoneSet, + localUnreadSet, + readStateVersion, + ], + ); const markItemRead = React.useCallback( (itemId: string) => {