fix(desktop): project home read-state from the shared NIP-RS marker (#631)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-20 17:13:59 -04:00
committed by GitHub
parent 8dcf24f993
commit a96cb487f4
8 changed files with 245 additions and 56 deletions

View File

@@ -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],

View File

@@ -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,
}}
>
<HuddleProvider>

View File

@@ -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<AppShellContextValue>({
markChannelRead: () => {},
markChannelUnread: () => {},
openChannelManagement: () => {},
getChannelReadAt: () => null,
readStateVersion: 0,
});
export function AppShellProvider({

View File

@@ -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,
};
}

View File

@@ -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({
}
>
<InboxListPane
doneSet={doneSet}
doneSet={effectiveDoneSet}
filter={filter}
items={filteredItems}
onFilterChange={setFilter}
onSelect={(itemId) => {
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}

View File

@@ -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<string>;
/** 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<ReadonlySet<string>>(() => {
const result = new Set<string>();
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 };
}

View File

@@ -259,6 +259,15 @@ export function useHomeFeedNotificationState(
settings: NotificationSettings,
setDesktopEnabled: (enabled: boolean) => Promise<boolean>,
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<string[]>(() =>
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,
};

View File

@@ -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), {