mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
109
desktop/src/features/home/useHomeInboxReadState.ts
Normal file
109
desktop/src/features/home/useHomeInboxReadState.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user