Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
65 changes: 44 additions & 21 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -565,6 +586,8 @@ export function AppShell() {
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
getChannelReadAt,
readStateVersion,
}}
>
<HuddleProvider>
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/channels/useUnreadChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
33 changes: 26 additions & 7 deletions desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -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}
Expand Down
109 changes: 109 additions & 0 deletions desktop/src/features/home/useHomeInboxReadState.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading