From 7864522740f130401579e37bfe7a841cef5fe4ee Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 27 May 2026 15:53:44 -0400 Subject: [PATCH 1/7] feat(desktop): thread-aware notification filtering + follow thread action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All channel messages — including thread replies from threads the user has no involvement in — triggered equal badge/bounce/toast noise. Adds a client-side notification filter so only top-level messages, broadcast replies, and thread replies in participated/followed threads fire notifications. Participation is detected from the user's own replies via the existing catch-up REQ and a new onSelfChannelMessage live path. Also adds a "Follow thread" / "Unfollow thread" action to the message action bar in the thread panel, backed by a localStorage-persisted per- pubkey follow set (500-entry LRU cap, v1 key with cross-device sync deferred to a future NIP-RS extension). --- desktop/scripts/check-file-sizes.mjs | 6 +- desktop/src/app/AppShell.tsx | 12 ++ desktop/src/app/AppShellContext.tsx | 6 + .../src/features/channels/ui/ChannelPane.tsx | 9 ++ .../features/channels/ui/ChannelScreen.tsx | 23 +++- .../channels/useLiveChannelUpdates.ts | 24 +++- .../features/channels/useUnreadChannels.ts | 44 +++++++ .../features/messages/lib/useThreadFollows.ts | 118 ++++++++++++++++++ .../features/messages/ui/MessageActionBar.tsx | 38 ++++++ .../src/features/messages/ui/MessageRow.tsx | 10 ++ .../messages/ui/MessageThreadPanel.tsx | 13 ++ .../notifications/lib/shouldNotify.ts | 29 +++++ 12 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 desktop/src/features/messages/lib/useThreadFollows.ts create mode 100644 desktop/src/features/notifications/lib/shouldNotify.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 91e8c6359..6aa38bdc5 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,11 +34,11 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas) ["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", 835], // 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) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + ["src/app/AppShell.tsx", 850], // 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) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring ["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", 525], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget - ["src/features/channels/ui/ChannelScreen.tsx", 555], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + ["src/features/channels/ui/ChannelPane.tsx", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough + ["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context ["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 diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index a51c2011b..1d56c8cfa 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -21,6 +21,7 @@ import { useOpenDmMutation, } from "@/features/channels/hooks"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; +import { useThreadFollows } from "@/features/messages/lib/useThreadFollows"; import { useHomeFeedNotifications, useHomeFeedNotificationState, @@ -271,6 +272,13 @@ export function AppShell() { [channels, selectedChannelId], ); + const { + followedRootIds, + isFollowing: isFollowingThread, + followThread, + unfollowThread, + } = useThreadFollows(identityQuery.data?.pubkey); + const { markAllChannelsRead, markChannelRead, @@ -291,6 +299,7 @@ export function AppShell() { onChannelMessage: handleChannelNotification, onDmMessage: handleDmNotification, onLiveMention: refetchHomeFeedOnLiveMention, + followedRootIds, }, ); @@ -609,6 +618,9 @@ export function AppShell() { }, getChannelReadAt, readStateVersion, + followThread, + unfollowThread, + isFollowingThread, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index f61df09e6..11f2efe55 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -18,6 +18,9 @@ type AppShellContextValue = { // Bump-counter that invalidates whenever the read marker changes. Include // in memo deps that consume getChannelReadAt. readStateVersion: number; + followThread: (rootId: string) => void; + unfollowThread: (rootId: string) => void; + isFollowingThread: (rootId: string) => boolean; }; const AppShellContext = React.createContext({ @@ -27,6 +30,9 @@ const AppShellContext = React.createContext({ openChannelManagement: () => {}, getChannelReadAt: () => null, readStateVersion: 0, + followThread: () => {}, + unfollowThread: () => {}, + isFollowingThread: () => false, }); export function AppShellProvider({ diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index fac3afc33..c057df5b7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -90,6 +90,9 @@ type ChannelPaneProps = { threadScrollTargetId: string | null; targetMessageId: string | null; typingPubkeys: string[]; + isFollowingThread?: boolean; + onFollowThread?: () => void; + onUnfollowThread?: () => void; }; export const ChannelPane = React.memo(function ChannelPane({ @@ -103,6 +106,7 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder, hasOlderMessages, isFetchingOlder, + isFollowingThread, isJoining = false, isSending, isTimelineLoading, @@ -115,6 +119,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete, onEdit, onEditSave, + onFollowThread, onMarkUnread, onExpandThreadReplies, onJoinChannel, @@ -127,6 +132,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onThreadScrollTargetResolved, onTargetReached, onToggleReaction, + onUnfollowThread, personaLookup, profiles, openThreadHeadId, @@ -369,6 +375,7 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey={currentPubkey} disabled={isComposerDisabled} editTarget={threadEditTarget} + isFollowingThread={isFollowingThread} isSending={isSending} onCancelEdit={onCancelEdit} onCancelReply={onCancelThreadReply} @@ -376,12 +383,14 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete={onDelete} onEdit={onEdit} onEditSave={onEditSave} + onFollowThread={onFollowThread} onMarkUnread={onMarkUnread} onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} onScrollTargetResolved={onThreadScrollTargetResolved} onToggleReaction={onToggleReaction} + onUnfollowThread={onUnfollowThread} profiles={profiles} replyTargetId={threadReplyTargetId} replyTargetMessage={threadReplyTargetMessage} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index c0cf8733e..ae8b742ac 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -81,8 +81,14 @@ export function ChannelScreen({ targetMessageEvent, targetMessageId, }: ChannelScreenProps) { - const { markChannelRead, markChannelUnread, openChannelManagement } = - useAppShell(); + const { + markChannelRead, + markChannelUnread, + openChannelManagement, + followThread, + unfollowThread, + isFollowingThread, + } = useAppShell(); const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< string | null >(null); @@ -90,6 +96,8 @@ export function ChannelScreen({ const [openThreadHeadId, setOpenThreadHeadId] = React.useState( null, ); + const isFollowingCurrentThread = + openThreadHeadId != null ? isFollowingThread(openThreadHeadId) : false; const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( () => new Set(), ); @@ -490,11 +498,22 @@ export function ChannelScreen({ } : null } + isFollowingThread={isFollowingCurrentThread} isSending={sendMessageMutation.isPending} isTimelineLoading={isTimelineLoading} messages={timelineMessages} onCancelEdit={handleCancelEdit} onCancelThreadReply={handleCancelThreadReply} + onFollowThread={ + openThreadHeadId != null + ? () => followThread(openThreadHeadId) + : undefined + } + onUnfollowThread={ + openThreadHeadId != null + ? () => unfollowThread(openThreadHeadId) + : undefined + } onCloseAgentSession={handleCloseAgentSession} onCloseThread={handleCloseThread} onDelete={ diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index c330ad2e4..31d7dddd6 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -5,6 +5,7 @@ import { channelsQueryKey } from "@/features/channels/hooks"; import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { getChannelIdFromTags } from "@/features/messages/lib/threading"; +import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import { relayClient } from "@/shared/api/relayClient"; import { CHANNEL_EVENT_KINDS, @@ -23,6 +24,9 @@ export type UseLiveChannelUpdatesOptions = { * unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set. */ onChannelMessage?: (channelId: string, event: RelayEvent) => void; + onSelfChannelMessage?: (event: RelayEvent) => void; + participatedRootIds?: ReadonlySet; + followedRootIds?: ReadonlySet; }; const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000; @@ -32,6 +36,8 @@ const LIVE_SUBSCRIPTION_RETRY_MAX_MS = 30_000; // catch-up query in useUnreadChannels so the two paths stay in lockstep. const UNREAD_TRIGGER_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); +const EMPTY_SET: ReadonlySet = new Set(); + function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { return ( currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey @@ -144,6 +150,16 @@ export function useLiveChannelUpdates( return; } + // Let the caller observe self-authored trigger events (e.g. to track + // thread participation) before the author-exclusion guard filters them. + if ( + UNREAD_TRIGGER_KINDS.has(event.kind) && + normalizedCurrentPubkey.length > 0 && + event.pubkey.toLowerCase() === normalizedCurrentPubkey + ) { + options.onSelfChannelMessage?.(event); + } + // Notify the unread tracker. Restricted to human-visible message kinds // and to events authored by someone other than the current user — your // own outgoing messages should never make a channel unread, and @@ -152,7 +168,13 @@ export function useLiveChannelUpdates( UNREAD_TRIGGER_KINDS.has(event.kind) && channelId !== activeChannelId && (normalizedCurrentPubkey.length === 0 || - event.pubkey.toLowerCase() !== normalizedCurrentPubkey) + event.pubkey.toLowerCase() !== normalizedCurrentPubkey) && + shouldNotifyForEvent( + event, + normalizedCurrentPubkey, + options.participatedRootIds ?? EMPTY_SET, + options.followedRootIds ?? EMPTY_SET, + ) ) { options.onChannelMessage?.(channelId, event); } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index e6ab70954..d29d311a5 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -4,6 +4,8 @@ import { type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; import { useReadState } from "@/features/channels/readState/useReadState"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; @@ -20,6 +22,8 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; +const EMPTY_SET: ReadonlySet = new Set(); + function parseTimestamp(value: string | null | undefined) { if (!value) { return null; @@ -74,6 +78,10 @@ export function useUnreadChannels( // against. Cleared when the user opens the channel. const forcedUnreadRef = React.useRef(new Set()); + // Root event IDs of threads where the current user has replied at least once. + // Used to determine if thread replies should trigger unread notifications. + const participatedRootIdsRef = React.useRef(new Set()); + // Tracks which channels we've already issued a catch-up REQ for this // session. Prevents re-fetching on every channels-list refetch, while still // letting newly-joined channels be caught up. Reset on identity change. @@ -92,6 +100,7 @@ export function useUnreadChannels( latestByChannelRef.current = new Map(); forcedUnreadRef.current = new Set(); caughtUpChannelsRef.current = new Set(); + participatedRootIdsRef.current = new Set(); bumpLatestVersion(); }, [pubkey, relayClient]); @@ -165,9 +174,19 @@ export function useUnreadChannels( [callerOnChannelMessage], ); + const handleSelfChannelMessage = React.useCallback((event: RelayEvent) => { + const ref = getThreadReference(event.tags); + if (ref.rootId !== null) { + participatedRootIdsRef.current.add(ref.rootId); + } + }, []); + useLiveChannelUpdates(channels, activeChannelId, { ...liveUpdateOptions, onChannelMessage: handleChannelMessage, + onSelfChannelMessage: handleSelfChannelMessage, + participatedRootIds: participatedRootIdsRef.current, + followedRootIds: liveUpdateOptions.followedRootIds, }); // Effect-key the catch-up on the *set* of channel IDs, not the array @@ -184,6 +203,7 @@ export function useUnreadChannels( // NIP-RS read marker?" If yes, advance latestByChannelRef so the unread // predicate fires. This is the only way historical unreads survive an // app restart now that we don't persist any client-side "latest" state. + // biome-ignore lint/correctness/useExhaustiveDependencies: options.followedRootIds intentionally omitted — it's a Set reference that changes identity every render; the catch-up is a one-shot per-channel operation controlled by caughtUpChannelsRef, not reactive to follow changes React.useEffect(() => { if (!isReadStateReady) return; if (!relayClient) return; @@ -224,6 +244,20 @@ export function useUnreadChannels( limit: CATCH_UP_LIMIT, }); + // Pass 1: build participation from self-authored thread replies + for (const event of events) { + if ( + normalizedPubkey !== null && + event.pubkey.toLowerCase() === normalizedPubkey + ) { + const ref = getThreadReference(event.tags); + if (ref.rootId !== null) { + participatedRootIdsRef.current.add(ref.rootId); + } + } + } + + // Pass 2: compute maxExternal, applying notification filter let maxExternal = 0; for (const event of events) { if ( @@ -233,6 +267,16 @@ export function useUnreadChannels( continue; } if (readAt !== null && event.created_at <= readAt) continue; + if ( + !shouldNotifyForEvent( + event, + normalizedPubkey ?? "", + participatedRootIdsRef.current, + options.followedRootIds ?? EMPTY_SET, + ) + ) { + continue; + } if (event.created_at > maxExternal) { maxExternal = event.created_at; } diff --git a/desktop/src/features/messages/lib/useThreadFollows.ts b/desktop/src/features/messages/lib/useThreadFollows.ts new file mode 100644 index 000000000..25732e344 --- /dev/null +++ b/desktop/src/features/messages/lib/useThreadFollows.ts @@ -0,0 +1,118 @@ +import * as React from "react"; + +const STORAGE_KEY_PREFIX = "sprout-thread-follows.v1"; +const MAX_ENTRIES = 500; + +type ThreadFollowEntry = { + rootId: string; + followedAt: number; +}; + +function storageKey(pubkey: string): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}`; +} + +function readFromStorage(pubkey: string): ThreadFollowEntry[] { + try { + const raw = window.localStorage.getItem(storageKey(pubkey)); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter( + (entry): entry is ThreadFollowEntry => + typeof entry === "object" && + entry !== null && + typeof entry.rootId === "string" && + typeof entry.followedAt === "number", + ); + } catch { + return []; + } +} + +function writeToStorage(pubkey: string, entries: ThreadFollowEntry[]): void { + try { + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(entries)); + } catch { + // Ignore storage errors (private browsing, quota exceeded). + } +} + +function capEntries(entries: ThreadFollowEntry[]): ThreadFollowEntry[] { + if (entries.length <= MAX_ENTRIES) { + return entries; + } + return entries + .slice() + .sort((a, b) => a.followedAt - b.followedAt) + .slice(entries.length - MAX_ENTRIES); +} + +export function useThreadFollows(pubkey: string | undefined): { + followedRootIds: ReadonlySet; + isFollowing: (rootId: string) => boolean; + followThread: (rootId: string) => void; + unfollowThread: (rootId: string) => void; +} { + const [entries, setEntries] = React.useState(() => { + if (!pubkey) { + return []; + } + return readFromStorage(pubkey); + }); + + React.useEffect(() => { + if (!pubkey) { + setEntries([]); + return; + } + setEntries(readFromStorage(pubkey)); + }, [pubkey]); + + const followedRootIds = React.useMemo>( + () => new Set(entries.map((e) => e.rootId)), + [entries], + ); + + const isFollowing = React.useCallback( + (rootId: string) => followedRootIds.has(rootId), + [followedRootIds], + ); + + const followThread = React.useCallback( + (rootId: string) => { + if (!pubkey) { + return; + } + setEntries((prev) => { + if (prev.some((e) => e.rootId === rootId)) { + return prev; + } + const next = capEntries([...prev, { rootId, followedAt: Date.now() }]); + writeToStorage(pubkey, next); + return next; + }); + }, + [pubkey], + ); + + const unfollowThread = React.useCallback( + (rootId: string) => { + if (!pubkey) { + return; + } + setEntries((prev) => { + const next = prev.filter((e) => e.rootId !== rootId); + writeToStorage(pubkey, next); + return next; + }); + }, + [pubkey], + ); + + return { followedRootIds, isFollowing, followThread, unfollowThread }; +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index cfdc18a9d..5a25d8ce0 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -1,6 +1,8 @@ import Picker from "@emoji-mart/react"; import data from "@emoji-mart/data"; import { + BellOff, + BellRing, Copy, CornerUpLeft, EllipsisVertical, @@ -62,9 +64,12 @@ function MoreActionsMenu({ message, onDelete, onEdit, + onFollowThread, onMarkUnread, onOpenChange, + onUnfollowThread, open, + isFollowingThread, }: { /** Channel UUID for the "Copy link" action. When null/undefined, the * Copy link entry is hidden (e.g. inbox preview rows that don't have it). */ @@ -72,9 +77,12 @@ function MoreActionsMenu({ message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onFollowThread?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onOpenChange: (open: boolean) => void; + onUnfollowThread?: (message: TimelineMessage) => void; open: boolean; + isFollowingThread?: boolean; }) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); // Set true the moment the user picks "Edit message". The @@ -144,6 +152,25 @@ function MoreActionsMenu({ ) : null} + {onFollowThread || onUnfollowThread ? ( + { + if (isFollowingThread) { + onUnfollowThread?.(message); + } else { + onFollowThread?.(message); + } + }} + > + {isFollowingThread ? ( + + ) : ( + + )} + {isFollowingThread ? "Unfollow thread" : "Follow thread"} + + ) : null} + {hasCopyActions ? ( { @@ -236,12 +263,15 @@ export function MessageActionBar({ message, onDelete, onEdit, + onFollowThread, onMarkUnread, onReactionSelect, onReply, + onUnfollowThread, reactionErrorMessage = null, reactions, reactionPending = false, + isFollowingThread, }: { activeReplyTargetId?: string | null; /** Channel UUID — required for the "Copy link" action; when omitted the @@ -250,12 +280,15 @@ export function MessageActionBar({ message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onFollowThread?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onReactionSelect?: (emoji: string) => Promise; onReply?: (message: TimelineMessage) => void; + onUnfollowThread?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; reactions: TimelineReaction[]; reactionPending?: boolean; + isFollowingThread?: boolean; }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); @@ -266,6 +299,8 @@ export function MessageActionBar({ Boolean(onEdit) || Boolean(onDelete) || Boolean(onMarkUnread) || + Boolean(onFollowThread) || + Boolean(onUnfollowThread) || !message.pending; if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) { @@ -386,9 +421,12 @@ export function MessageActionBar({ message={message} onDelete={onDelete} onEdit={onEdit} + onFollowThread={onFollowThread} onMarkUnread={onMarkUnread} onOpenChange={setIsDropdownOpen} + onUnfollowThread={onUnfollowThread} open={isDropdownOpen} + isFollowingThread={isFollowingThread} /> ) : null} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 786f9c876..532ad01da 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -30,23 +30,28 @@ export const MessageRow = React.memo( activeReplyTargetId = null, channelId = null, highlighted = false, + isFollowingThread, layoutVariant = "default", message, onDelete, onEdit, + onFollowThread, onMarkUnread, onToggleReaction, onReply, + onUnfollowThread, profiles, searchQuery, }: { activeReplyTargetId?: string | null; channelId?: string | null; highlighted?: boolean; + isFollowingThread?: boolean; layoutVariant?: "default" | "thread-reply"; message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onFollowThread?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -54,6 +59,7 @@ export const MessageRow = React.memo( remove: boolean, ) => Promise; onReply?: (message: TimelineMessage) => void; + onUnfollowThread?: (message: TimelineMessage) => void; profiles?: UserProfileLookup; searchQuery?: string; }) { @@ -201,14 +207,17 @@ export const MessageRow = React.memo( void; + onUnfollowThread?: () => void; }; function canManageMessage( @@ -89,12 +92,14 @@ export function MessageThreadPanel({ disabled = false, editTarget, isSending, + isFollowingThread, onCancelEdit, onCancelReply, onClose, onDelete, onEdit, onEditSave, + onFollowThread, onMarkUnread, onExpandReplies, onResetWidth, @@ -103,6 +108,7 @@ export function MessageThreadPanel({ onSelectReplyTarget, onSend, onToggleReaction, + onUnfollowThread, profiles, replyTargetId, replyTargetMessage, @@ -213,6 +219,7 @@ export function MessageThreadPanel({ onFollowThread() : undefined + } onMarkUnread={onMarkUnread} onToggleReaction={onToggleReaction} + onUnfollowThread={ + onUnfollowThread ? (_msg) => onUnfollowThread() : undefined + } profiles={profiles} /> diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts new file mode 100644 index 000000000..d855fceff --- /dev/null +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -0,0 +1,29 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { getThreadReference } from "@/features/messages/lib/threading"; + +export function shouldNotifyForEvent( + event: RelayEvent, + _currentPubkey: string, + participatedRootIds: ReadonlySet, + followedRootIds: ReadonlySet, +): boolean { + const { parentId, rootId } = getThreadReference(event.tags); + + if (parentId === null) { + return true; + } + + if (event.tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1")) { + return true; + } + + if (rootId !== null && participatedRootIds.has(rootId)) { + return true; + } + + if (rootId !== null && followedRootIds.has(rootId)) { + return true; + } + + return false; +} From c80c0c14d18e1dd277cc2ff4bc952b9df450e02e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 27 May 2026 16:41:13 -0400 Subject: [PATCH 2/7] fix(desktop): address review feedback for thread-aware notifications Thread authors weren't auto-notified when someone replied to their post because top-level messages have no NIP-10 root tag. Added p-tag check to shouldNotifyForEvent so replies that include the author's pubkey in a p tag (Nostr convention) trigger notifications. Also persisted participatedRootIds to localStorage so participation survives restarts, fixed writeToStorage silently swallowing quota errors, extracted shared isBroadcastReply helper, deduplicated EMPTY_SET, added follow-thread action to main timeline thread roots, and added cross-tab storage sync. --- .../src/features/channels/ui/ChannelPane.tsx | 9 +++ .../features/channels/ui/ChannelScreen.tsx | 3 + .../channels/ui/useChannelRouteTarget.ts | 15 ++-- .../channels/useLiveChannelUpdates.ts | 2 +- .../features/channels/useUnreadChannels.ts | 75 +++++++++++++++++-- .../src/features/messages/lib/threadPanel.ts | 13 ++-- .../src/features/messages/lib/threading.ts | 4 + .../features/messages/lib/useThreadFollows.ts | 30 +++++++- .../features/messages/ui/MessageTimeline.tsx | 9 +++ .../messages/ui/TimelineMessageList.tsx | 19 +++++ .../notifications/lib/shouldNotify.ts | 18 ++++- 11 files changed, 164 insertions(+), 33 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index c057df5b7..e4979cc3d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -93,6 +93,9 @@ type ChannelPaneProps = { isFollowingThread?: boolean; onFollowThread?: () => void; onUnfollowThread?: () => void; + followThreadById?: (rootId: string) => void; + unfollowThreadById?: (rootId: string) => void; + isFollowingThreadById?: (rootId: string) => boolean; }; export const ChannelPane = React.memo(function ChannelPane({ @@ -106,7 +109,9 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder, hasOlderMessages, isFetchingOlder, + followThreadById, isFollowingThread, + isFollowingThreadById, isJoining = false, isSending, isTimelineLoading, @@ -133,6 +138,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onTargetReached, onToggleReaction, onUnfollowThread, + unfollowThreadById, personaLookup, profiles, openThreadHeadId, @@ -253,10 +259,13 @@ export const ChannelPane = React.memo(function ChannelPane({ scrollContainerRef={timelineScrollRef} currentPubkey={currentPubkey} fetchOlder={fetchOlder} + followThreadById={followThreadById} hasOlderMessages={hasOlderMessages} isFetchingOlder={isFetchingOlder} + isFollowingThreadById={isFollowingThreadById} personaLookup={personaLookup} profiles={profiles} + unfollowThreadById={unfollowThreadById} emptyDescription={ activeChannel?.channelType === "forum" ? "Select a stream or DM to load real message history in this first integration pass." diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index ae8b742ac..51c1b3c06 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -498,6 +498,9 @@ export function ChannelScreen({ } : null } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} isFollowingThread={isFollowingCurrentThread} isSending={sendMessageMutation.isPending} isTimelineLoading={isTimelineLoading} diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 85209207f..ffc11dac1 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -1,15 +1,9 @@ import * as React from "react"; import type { TimelineMessage } from "@/features/messages/types"; +import { isBroadcastReply } from "@/features/messages/lib/threading"; import type { Channel } from "@/shared/api/types"; -function isBroadcastReply(message: TimelineMessage): boolean { - return ( - message.tags?.some((tag) => tag[0] === "broadcast" && tag[1] === "1") ?? - false - ); -} - function getThreadRouteTarget( targetMessage: TimelineMessage, messageById: ReadonlyMap, @@ -50,7 +44,7 @@ function getRouteMainTimelineTargetId( return null; } - if (!targetMessage?.parentId || isBroadcastReply(targetMessage)) { + if (!targetMessage?.parentId || isBroadcastReply(targetMessage.tags ?? [])) { return targetMessageId; } @@ -115,7 +109,10 @@ export function useChannelRouteTarget({ } const targetMessage = timelineMessageById.get(targetMessageId) ?? null; - if (!targetMessage?.parentId || isBroadcastReply(targetMessage)) { + if ( + !targetMessage?.parentId || + isBroadcastReply(targetMessage.tags ?? []) + ) { return; } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 31d7dddd6..7dc64c6a6 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -36,7 +36,7 @@ const LIVE_SUBSCRIPTION_RETRY_MAX_MS = 30_000; // catch-up query in useUnreadChannels so the two paths stay in lockstep. const UNREAD_TRIGGER_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); -const EMPTY_SET: ReadonlySet = new Set(); +export const EMPTY_SET: ReadonlySet = new Set(); function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { return ( diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index d29d311a5..091e3509a 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -1,5 +1,6 @@ import * as React from "react"; import { + EMPTY_SET, useLiveChannelUpdates, type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; @@ -22,7 +23,47 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; -const EMPTY_SET: ReadonlySet = new Set(); +const PARTICIPATION_STORAGE_PREFIX = "sprout-thread-participation.v1"; +const MAX_PARTICIPATION_ENTRIES = 1000; + +function participationStorageKey(pubkey: string): string { + return `${PARTICIPATION_STORAGE_PREFIX}:${pubkey}`; +} + +function readParticipationFromStorage(pubkey: string): Set { + try { + const raw = window.localStorage.getItem(participationStorageKey(pubkey)); + if (!raw) { + return new Set(); + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((id): id is string => typeof id === "string")); + } catch { + return new Set(); + } +} + +function writeParticipationToStorage( + pubkey: string, + rootIds: Set, +): void { + try { + const arr = [...rootIds]; + const capped = + arr.length > MAX_PARTICIPATION_ENTRIES + ? arr.slice(arr.length - MAX_PARTICIPATION_ENTRIES) + : arr; + window.localStorage.setItem( + participationStorageKey(pubkey), + JSON.stringify(capped), + ); + } catch { + // Ignore storage errors (private browsing, quota exceeded). + } +} function parseTimestamp(value: string | null | undefined) { if (!value) { @@ -100,7 +141,9 @@ export function useUnreadChannels( latestByChannelRef.current = new Map(); forcedUnreadRef.current = new Set(); caughtUpChannelsRef.current = new Set(); - participatedRootIdsRef.current = new Set(); + participatedRootIdsRef.current = pubkey + ? readParticipationFromStorage(pubkey) + : new Set(); bumpLatestVersion(); }, [pubkey, relayClient]); @@ -174,12 +217,21 @@ export function useUnreadChannels( [callerOnChannelMessage], ); - const handleSelfChannelMessage = React.useCallback((event: RelayEvent) => { - const ref = getThreadReference(event.tags); - if (ref.rootId !== null) { - participatedRootIdsRef.current.add(ref.rootId); - } - }, []); + const handleSelfChannelMessage = React.useCallback( + (event: RelayEvent) => { + const ref = getThreadReference(event.tags); + if (ref.rootId !== null) { + participatedRootIdsRef.current.add(ref.rootId); + if (normalizedPubkey !== null) { + writeParticipationToStorage( + normalizedPubkey, + participatedRootIdsRef.current, + ); + } + } + }, + [normalizedPubkey], + ); useLiveChannelUpdates(channels, activeChannelId, { ...liveUpdateOptions, @@ -257,6 +309,13 @@ export function useUnreadChannels( } } + if (normalizedPubkey !== null) { + writeParticipationToStorage( + normalizedPubkey, + participatedRootIdsRef.current, + ); + } + // Pass 2: compute maxExternal, applying notification filter let maxExternal = 0; for (const event of events) { diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 5d8d0818b..03baa1dd5 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -1,4 +1,5 @@ import type { TimelineMessage } from "@/features/messages/types"; +import { isBroadcastReply } from "@/features/messages/lib/threading"; type ThreadPanelData = { threadHead: TimelineMessage | null; @@ -33,13 +34,6 @@ type ThreadDescendantStats = { const MAX_SUMMARY_PARTICIPANTS = 3; -function isBroadcastReply(message: TimelineMessage): boolean { - return ( - message.tags?.some((tag) => tag[0] === "broadcast" && tag[1] === "1") ?? - false - ); -} - function normalizeHeadMessage(message: TimelineMessage): TimelineMessage { return { ...message, @@ -230,7 +224,10 @@ export function buildMainTimelineEntries( const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages); return messages - .filter((message) => message.parentId == null || isBroadcastReply(message)) + .filter( + (message) => + message.parentId == null || isBroadcastReply(message.tags ?? []), + ) .map((message) => { return { message, diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index 54d265ffb..f942aaa77 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -13,6 +13,10 @@ export function getChannelIdFromTags(tags: string[][]) { return tags.find((tag) => tag[0] === "h")?.[1] ?? null; } +export function isBroadcastReply(tags: string[][]): boolean { + return tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1"); +} + export function getThreadReference(tags: string[][]): ThreadReference { const eventTags = getEventTags(tags); diff --git a/desktop/src/features/messages/lib/useThreadFollows.ts b/desktop/src/features/messages/lib/useThreadFollows.ts index 25732e344..b78b6b358 100644 --- a/desktop/src/features/messages/lib/useThreadFollows.ts +++ b/desktop/src/features/messages/lib/useThreadFollows.ts @@ -34,11 +34,12 @@ function readFromStorage(pubkey: string): ThreadFollowEntry[] { } } -function writeToStorage(pubkey: string, entries: ThreadFollowEntry[]): void { +function writeToStorage(pubkey: string, entries: ThreadFollowEntry[]): boolean { try { window.localStorage.setItem(storageKey(pubkey), JSON.stringify(entries)); + return true; } catch { - // Ignore storage errors (private browsing, quota exceeded). + return false; } } @@ -73,6 +74,23 @@ export function useThreadFollows(pubkey: string | undefined): { setEntries(readFromStorage(pubkey)); }, [pubkey]); + React.useEffect(() => { + if (!pubkey) { + return; + } + const key = storageKey(pubkey); + const handler = (e: StorageEvent) => { + if (e.key !== key) { + return; + } + setEntries(readFromStorage(pubkey)); + }; + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + }; + }, [pubkey]); + const followedRootIds = React.useMemo>( () => new Set(entries.map((e) => e.rootId)), [entries], @@ -93,7 +111,9 @@ export function useThreadFollows(pubkey: string | undefined): { return prev; } const next = capEntries([...prev, { rootId, followedAt: Date.now() }]); - writeToStorage(pubkey, next); + if (!writeToStorage(pubkey, next)) { + return prev; + } return next; }); }, @@ -107,7 +127,9 @@ export function useThreadFollows(pubkey: string | undefined): { } setEntries((prev) => { const next = prev.filter((e) => e.rootId !== rootId); - writeToStorage(pubkey, next); + if (!writeToStorage(pubkey, next)) { + return prev; + } return next; }); }, diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 3fd70ba63..65ba2f3f9 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -30,10 +30,13 @@ type MessageTimelineProps = { /** Map from lowercase pubkey → persona display name for bot members. */ personaLookup?: Map; profiles?: UserProfileLookup; + followThreadById?: (rootId: string) => void; + isFollowingThreadById?: (rootId: string) => boolean; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + unfollowThreadById?: (rootId: string) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -60,6 +63,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({ fetchOlder, hasOlderMessages = true, isFetchingOlder = false, + followThreadById, + isFollowingThreadById, messageFooters, personaLookup, profiles, @@ -68,6 +73,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onMarkUnread, onReply, onToggleReaction, + unfollowThreadById, scrollContainerRef: externalScrollRef, searchActiveMessageId = null, searchMatchingMessageIds, @@ -186,7 +192,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ activeReplyTargetId={activeReplyTargetId} channelId={channelId} currentPubkey={currentPubkey} + followThreadById={followThreadById} highlightedMessageId={highlightedMessageId} + isFollowingThreadById={isFollowingThreadById} messageFooters={messageFooters} messages={messages} onDelete={onDelete} @@ -199,6 +207,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ searchActiveMessageId={searchActiveMessageId} searchMatchingMessageIds={searchMatchingMessageIds} searchQuery={searchQuery} + unfollowThreadById={unfollowThreadById} /> ) : null} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 96b2388c8..94e33c337 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -18,13 +18,16 @@ type TimelineMessageListProps = { activeReplyTargetId?: string | null; channelId?: string | null; currentPubkey?: string; + followThreadById?: (rootId: string) => void; highlightedMessageId?: string | null; + isFollowingThreadById?: (rootId: string) => boolean; messageFooters?: Record; messages: TimelineMessage[]; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + unfollowThreadById?: (rootId: string) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -45,7 +48,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId = null, channelId, currentPubkey, + followThreadById, highlightedMessageId = null, + isFollowingThreadById, messageFooters, messages, onDelete, @@ -58,6 +63,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + unfollowThreadById, }: TimelineMessageListProps) { const entries = React.useMemo( () => buildMainTimelineEntries(messages), @@ -113,6 +119,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId={activeReplyTargetId} channelId={channelId} highlighted={false} + isFollowingThread={ + isFollowingThreadById + ? isFollowingThreadById(message.id) + : undefined + } message={message} onDelete={ onDelete && currentPubkey && message.pubkey === currentPubkey @@ -124,9 +135,17 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ? onEdit : undefined } + onFollowThread={ + followThreadById ? () => followThreadById(message.id) : undefined + } onMarkUnread={onMarkUnread} onToggleReaction={onToggleReaction} onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } profiles={profiles} /> , followedRootIds: ReadonlySet, ): boolean { @@ -13,7 +16,16 @@ export function shouldNotifyForEvent( return true; } - if (event.tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1")) { + if (isBroadcastReply(event.tags)) { + return true; + } + + if ( + currentPubkey.length > 0 && + event.tags.some( + (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey, + ) + ) { return true; } From 527537625b61b91ed946f9ba2a6a08e4799bd7d9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 27 May 2026 18:03:06 -0400 Subject: [PATCH 3/7] fix(desktop): thread author notifications, follow button state, and activity feed Thread authors weren't notified about replies because buildReplyTags adds the replier's pubkey, not the root author's. Rather than fix p-tags, track authored root IDs client-side (same pattern as participatedRootIds) and check them in shouldNotifyForEvent. The "Follow thread" button only checked explicit follows, showing "Follow thread" even when the user was already notified via authorship or participation. Exposed a combined isNotifiedForThread predicate and show a disabled "Following" indicator for auto-notified threads. Thread reply notifications only manifested as channel unread badges with no way to identify which thread triggered them. Split the notification path so thread replies route to a new Home activity feed instead of channel badges, making them visible in the Activity tab. --- desktop/scripts/check-file-sizes.mjs | 3 +- desktop/src/app/AppShell.tsx | 13 ++ desktop/src/app/AppShellContext.tsx | 5 + .../features/channels/ui/ChannelScreen.tsx | 12 +- .../channels/useLiveChannelUpdates.ts | 18 +- .../features/channels/useUnreadChannels.ts | 206 +++++++++++++++++- desktop/src/features/home/ui/HomeScreen.tsx | 38 +++- .../features/messages/ui/MessageActionBar.tsx | 5 + desktop/src/features/notifications/hooks.ts | 10 +- .../src/features/notifications/lib/feed.ts | 6 +- .../notifications/lib/shouldNotify.ts | 5 + 11 files changed, 306 insertions(+), 15 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6aa38bdc5..0aead0d45 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,11 +34,12 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas) ["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", 850], // 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) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring + ["src/app/AppShell.tsx", 865], // 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) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring + isNotifiedForThread combined predicate + threadActivityItems context plumbing ["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", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough ["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context + ["src/features/channels/useUnreadChannels.ts", 650], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items ["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 diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 1d56c8cfa..cbfd3d814 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -286,6 +286,9 @@ export function AppShell() { unreadChannelIds, getEffectiveTimestamp: getChannelReadAt, readStateVersion, + participatedRootIds, + authoredRootIds, + threadActivityItems, } = useUnreadChannels( sidebarChannels, activeChannel, @@ -319,6 +322,14 @@ export function AppShell() { feedProfilesQuery.data?.profiles, ); + const isNotifiedForThread = React.useCallback( + (rootId: string) => + followedRootIds.has(rootId) || + participatedRootIds.has(rootId) || + authoredRootIds.has(rootId), + [followedRootIds, participatedRootIds, authoredRootIds], + ); + const createChannelMutation = useCreateChannelMutation(); const createForumMutation = useCreateChannelMutation(); const { applyCanvas, applyAgents } = useApplyTemplate(); @@ -621,6 +632,8 @@ export function AppShell() { followThread, unfollowThread, isFollowingThread, + isNotifiedForThread, + threadActivityItems, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 11f2efe55..1dc494045 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; type AppShellContextValue = { markAllChannelsRead: () => void; @@ -21,6 +22,8 @@ type AppShellContextValue = { followThread: (rootId: string) => void; unfollowThread: (rootId: string) => void; isFollowingThread: (rootId: string) => boolean; + isNotifiedForThread: (rootId: string) => boolean; + threadActivityItems: ThreadActivityItem[]; }; const AppShellContext = React.createContext({ @@ -33,6 +36,8 @@ const AppShellContext = React.createContext({ followThread: () => {}, unfollowThread: () => {}, isFollowingThread: () => false, + isNotifiedForThread: () => false, + threadActivityItems: [], }); export function AppShellProvider({ diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 51c1b3c06..a415cd58b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -88,6 +88,7 @@ export function ChannelScreen({ followThread, unfollowThread, isFollowingThread, + isNotifiedForThread, } = useAppShell(); const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< string | null @@ -96,7 +97,9 @@ export function ChannelScreen({ const [openThreadHeadId, setOpenThreadHeadId] = React.useState( null, ); - const isFollowingCurrentThread = + const isNotifiedForCurrentThread = + openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false; + const isExplicitlyFollowingCurrentThread = openThreadHeadId != null ? isFollowingThread(openThreadHeadId) : false; const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( () => new Set(), @@ -501,19 +504,20 @@ export function ChannelScreen({ followThreadById={followThread} unfollowThreadById={unfollowThread} isFollowingThreadById={isFollowingThread} - isFollowingThread={isFollowingCurrentThread} + isFollowingThread={isNotifiedForCurrentThread} isSending={sendMessageMutation.isPending} isTimelineLoading={isTimelineLoading} messages={timelineMessages} onCancelEdit={handleCancelEdit} onCancelThreadReply={handleCancelThreadReply} onFollowThread={ - openThreadHeadId != null + openThreadHeadId != null && !isNotifiedForCurrentThread ? () => followThread(openThreadHeadId) : undefined } onUnfollowThread={ - openThreadHeadId != null + openThreadHeadId != null && + isExplicitlyFollowingCurrentThread ? () => unfollowThread(openThreadHeadId) : undefined } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 7dc64c6a6..f24ff86dd 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -4,7 +4,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { channelsQueryKey } from "@/features/channels/hooks"; import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; -import { getChannelIdFromTags } from "@/features/messages/lib/threading"; +import { + getChannelIdFromTags, + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import { relayClient } from "@/shared/api/relayClient"; import { @@ -24,9 +28,11 @@ export type UseLiveChannelUpdatesOptions = { * unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set. */ onChannelMessage?: (channelId: string, event: RelayEvent) => void; + onThreadReplyNotification?: (channelId: string, event: RelayEvent) => void; onSelfChannelMessage?: (event: RelayEvent) => void; participatedRootIds?: ReadonlySet; followedRootIds?: ReadonlySet; + authoredRootIds?: ReadonlySet; }; const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000; @@ -174,9 +180,17 @@ export function useLiveChannelUpdates( normalizedCurrentPubkey, options.participatedRootIds ?? EMPTY_SET, options.followedRootIds ?? EMPTY_SET, + options.authoredRootIds ?? EMPTY_SET, ) ) { - options.onChannelMessage?.(channelId, event); + const ref = getThreadReference(event.tags); + const isThreadReply = + ref.parentId !== null && !isBroadcastReply(event.tags); + if (isThreadReply) { + options.onThreadReplyNotification?.(channelId, event); + } else { + options.onChannelMessage?.(channelId, event); + } } // Merge into the timeline cache for the active channel. diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 091e3509a..41a450ec3 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -5,7 +5,10 @@ import { type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; import { useReadState } from "@/features/channels/readState/useReadState"; -import { getThreadReference } from "@/features/messages/lib/threading"; +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; @@ -65,6 +68,98 @@ function writeParticipationToStorage( } } +const AUTHORED_STORAGE_PREFIX = "sprout-thread-authored.v1"; +const MAX_AUTHORED_ENTRIES = 1000; + +function authoredStorageKey(pubkey: string): string { + return `${AUTHORED_STORAGE_PREFIX}:${pubkey}`; +} + +function readAuthoredFromStorage(pubkey: string): Set { + try { + const raw = window.localStorage.getItem(authoredStorageKey(pubkey)); + if (!raw) { + return new Set(); + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((id): id is string => typeof id === "string")); + } catch { + return new Set(); + } +} + +function writeAuthoredToStorage(pubkey: string, rootIds: Set): void { + try { + const arr = [...rootIds]; + const capped = + arr.length > MAX_AUTHORED_ENTRIES + ? arr.slice(arr.length - MAX_AUTHORED_ENTRIES) + : arr; + window.localStorage.setItem( + authoredStorageKey(pubkey), + JSON.stringify(capped), + ); + } catch { + // Ignore storage errors (private browsing, quota exceeded). + } +} + +export type ThreadActivityItem = { + id: string; + kind: number; + pubkey: string; + content: string; + createdAt: number; + channelId: string; + channelName: string; + tags: string[][]; +}; + +const ACTIVITY_STORAGE_PREFIX = "sprout-thread-activity.v1"; +const MAX_ACTIVITY_ITEMS = 100; + +function activityStorageKey(pubkey: string): string { + return `${ACTIVITY_STORAGE_PREFIX}:${pubkey}`; +} + +function readActivityFromStorage(pubkey: string): ThreadActivityItem[] { + try { + const raw = window.localStorage.getItem(activityStorageKey(pubkey)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item): item is ThreadActivityItem => + typeof item === "object" && + item !== null && + typeof item.id === "string", + ); + } catch { + return []; + } +} + +function writeActivityToStorage( + pubkey: string, + items: ThreadActivityItem[], +): void { + try { + const capped = + items.length > MAX_ACTIVITY_ITEMS + ? items.slice(items.length - MAX_ACTIVITY_ITEMS) + : items; + window.localStorage.setItem( + activityStorageKey(pubkey), + JSON.stringify(capped), + ); + } catch { + // Ignore storage errors. + } +} + function parseTimestamp(value: string | null | undefined) { if (!value) { return null; @@ -123,6 +218,14 @@ export function useUnreadChannels( // Used to determine if thread replies should trigger unread notifications. const participatedRootIdsRef = React.useRef(new Set()); + // Root event IDs of top-level messages authored by the current user. + // Used to notify the author when someone replies to their posts. + const authoredRootIdsRef = React.useRef(new Set()); + + // Thread reply events that triggered notifications — surfaced in the Home + // activity feed as synthetic FeedItems. + const threadActivityRef = React.useRef([]); + // Tracks which channels we've already issued a catch-up REQ for this // session. Prevents re-fetching on every channels-list refetch, while still // letting newly-joined channels be caught up. Reset on identity change. @@ -144,6 +247,10 @@ export function useUnreadChannels( participatedRootIdsRef.current = pubkey ? readParticipationFromStorage(pubkey) : new Set(); + authoredRootIdsRef.current = pubkey + ? readAuthoredFromStorage(pubkey) + : new Set(); + threadActivityRef.current = pubkey ? readActivityFromStorage(pubkey) : []; bumpLatestVersion(); }, [pubkey, relayClient]); @@ -228,17 +335,54 @@ export function useUnreadChannels( participatedRootIdsRef.current, ); } + } else { + authoredRootIdsRef.current.add(event.id); + if (normalizedPubkey !== null) { + writeAuthoredToStorage(normalizedPubkey, authoredRootIdsRef.current); + } } }, [normalizedPubkey], ); + const handleThreadReplyNotification = React.useCallback( + (channelId: string, event: RelayEvent) => { + const channelName = + channels.find((ch) => ch.id === channelId)?.name ?? ""; + const item: ThreadActivityItem = { + id: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + createdAt: event.created_at, + channelId, + channelName, + tags: [...event.tags], + }; + const existing = threadActivityRef.current; + if (existing.some((e) => e.id === item.id)) return; + const next = [...existing, item]; + const capped = + next.length > MAX_ACTIVITY_ITEMS + ? next.slice(next.length - MAX_ACTIVITY_ITEMS) + : next; + threadActivityRef.current = capped; + if (normalizedPubkey !== null) { + writeActivityToStorage(normalizedPubkey, capped); + } + bumpLatestVersion(); + }, + [channels, normalizedPubkey], + ); + useLiveChannelUpdates(channels, activeChannelId, { ...liveUpdateOptions, onChannelMessage: handleChannelMessage, + onThreadReplyNotification: handleThreadReplyNotification, onSelfChannelMessage: handleSelfChannelMessage, participatedRootIds: participatedRootIdsRef.current, followedRootIds: liveUpdateOptions.followedRootIds, + authoredRootIds: authoredRootIdsRef.current, }); // Effect-key the catch-up on the *set* of channel IDs, not the array @@ -277,7 +421,12 @@ export function useUnreadChannels( let isCancelled = false; type CatchUpResult = - | { channelId: string; ok: true; maxExternal: number } + | { + channelId: string; + ok: true; + maxExternal: number; + threadReplies: ThreadActivityItem[]; + } | { channelId: string; ok: false }; void Promise.all( @@ -297,6 +446,7 @@ export function useUnreadChannels( }); // Pass 1: build participation from self-authored thread replies + // and track self-authored top-level messages for author notifications for (const event of events) { if ( normalizedPubkey !== null && @@ -305,6 +455,8 @@ export function useUnreadChannels( const ref = getThreadReference(event.tags); if (ref.rootId !== null) { participatedRootIdsRef.current.add(ref.rootId); + } else { + authoredRootIdsRef.current.add(event.id); } } } @@ -314,10 +466,17 @@ export function useUnreadChannels( normalizedPubkey, participatedRootIdsRef.current, ); + writeAuthoredToStorage( + normalizedPubkey, + authoredRootIdsRef.current, + ); } - // Pass 2: compute maxExternal, applying notification filter + // Pass 2: compute maxExternal and collect thread reply activity, + // applying the notification filter to both. let maxExternal = 0; + const threadReplies: ThreadActivityItem[] = []; + const chName = channels.find((ch) => ch.id === channelId)?.name ?? ""; for (const event of events) { if ( normalizedPubkey !== null && @@ -332,6 +491,7 @@ export function useUnreadChannels( normalizedPubkey ?? "", participatedRootIdsRef.current, options.followedRootIds ?? EMPTY_SET, + authoredRootIdsRef.current, ) ) { continue; @@ -339,9 +499,22 @@ export function useUnreadChannels( if (event.created_at > maxExternal) { maxExternal = event.created_at; } + const evtRef = getThreadReference(event.tags); + if (evtRef.parentId !== null && !isBroadcastReply(event.tags)) { + threadReplies.push({ + id: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + createdAt: event.created_at, + channelId, + channelName: chName, + tags: [...event.tags], + }); + } } - return { channelId, ok: true, maxExternal }; + return { channelId, ok: true, maxExternal, threadReplies }; } catch { // Transient relay failure for this channel — release the claim // so we retry on the next effect run instead of staying stuck @@ -352,12 +525,14 @@ export function useUnreadChannels( ).then((results) => { if (isCancelled) return; let didAdvance = false; + const allThreadReplies: ThreadActivityItem[] = []; for (const result of results) { if (!result.ok) { caughtUpChannelsRef.current.delete(result.channelId); continue; } - const { channelId, maxExternal } = result; + const { channelId, maxExternal, threadReplies } = result; + allThreadReplies.push(...threadReplies); if (maxExternal === 0) continue; const current = latestByChannelRef.current.get(channelId) ?? 0; if (maxExternal > current) { @@ -365,6 +540,24 @@ export function useUnreadChannels( didAdvance = true; } } + if (allThreadReplies.length > 0) { + const existingIds = new Set(threadActivityRef.current.map((e) => e.id)); + const newItems = allThreadReplies.filter( + (item) => !existingIds.has(item.id), + ); + if (newItems.length > 0) { + const merged = [...threadActivityRef.current, ...newItems]; + const capped = + merged.length > MAX_ACTIVITY_ITEMS + ? merged.slice(merged.length - MAX_ACTIVITY_ITEMS) + : merged; + threadActivityRef.current = capped; + if (normalizedPubkey) { + writeActivityToStorage(normalizedPubkey, capped); + } + didAdvance = true; + } + } if (didAdvance) bumpLatestVersion(); }); @@ -445,5 +638,8 @@ export function useUnreadChannels( // should include in memo deps. getEffectiveTimestamp, readStateVersion, + participatedRootIds: participatedRootIdsRef.current as ReadonlySet, + authoredRootIds: authoredRootIdsRef.current as ReadonlySet, + threadActivityItems: threadActivityRef.current, }; } diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index 5534271cc..6080e5e84 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -1,6 +1,11 @@ +import * as React from "react"; + +import { useAppShell } from "@/app/AppShellContext"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; +import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; +import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; type HomeScreenProps = { availableChannelIds: ReadonlySet; @@ -14,6 +19,37 @@ export function HomeScreen({ onOpenContext, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); + const { threadActivityItems } = useAppShell(); + + const augmentedFeed = React.useMemo((): HomeFeedResponse | undefined => { + if (!homeFeedQuery.data) return undefined; + if (!threadActivityItems || threadActivityItems.length === 0) { + return homeFeedQuery.data; + } + + const syntheticItems: FeedItem[] = threadActivityItems.map( + (item: ThreadActivityItem) => ({ + id: item.id, + kind: item.kind, + pubkey: item.pubkey, + content: item.content, + createdAt: item.createdAt, + channelId: item.channelId, + channelName: item.channelName, + channelType: undefined, + tags: item.tags, + category: "activity" as const, + }), + ); + + return { + ...homeFeedQuery.data, + feed: { + ...homeFeedQuery.data.feed, + activity: [...homeFeedQuery.data.feed.activity, ...syntheticItems], + }, + }; + }, [homeFeedQuery.data, threadActivityItems]); return ( <> @@ -33,7 +69,7 @@ export function HomeScreen({ ? homeFeedQuery.error.message : undefined } - feed={homeFeedQuery.data} + feed={augmentedFeed} isLoading={homeFeedQuery.isLoading} onOpenContext={onOpenContext} onRefresh={() => { diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 5a25d8ce0..fcbe70071 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -169,6 +169,11 @@ function MoreActionsMenu({ )} {isFollowingThread ? "Unfollow thread" : "Follow thread"} + ) : isFollowingThread ? ( + + + Following + ) : null} {hasCopyActions ? ( diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 9e59f2397..6fb699eff 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -282,7 +282,14 @@ export function useHomeFeedNotificationState( readStoredSeenFeedIds(normalizedPubkey), ); const currentFeedItems = React.useMemo( - () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), + () => + feed + ? [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ] + : [], [feed], ); const currentFeedIds = React.useMemo( @@ -379,6 +386,7 @@ export function useHomeFeedNotifications(pubkey: string | undefined) { ? [ ...homeFeedQuery.data.feed.mentions, ...homeFeedQuery.data.feed.needsAction, + ...homeFeedQuery.data.feed.activity, ] : [], [homeFeedQuery.data], diff --git a/desktop/src/features/notifications/lib/feed.ts b/desktop/src/features/notifications/lib/feed.ts index 3529053af..6285daca1 100644 --- a/desktop/src/features/notifications/lib/feed.ts +++ b/desktop/src/features/notifications/lib/feed.ts @@ -44,7 +44,11 @@ export function notificationBody(item: FeedItem) { } export function collectHomeAlertItems(feed: HomeFeedResponse) { - return [...feed.feed.mentions, ...feed.feed.needsAction]; + return [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ]; } export function eligibleFeedNotificationItems( diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 7493354ef..9e663d9ee 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -9,6 +9,7 @@ export function shouldNotifyForEvent( currentPubkey: string, participatedRootIds: ReadonlySet, followedRootIds: ReadonlySet, + authoredRootIds: ReadonlySet, ): boolean { const { parentId, rootId } = getThreadReference(event.tags); @@ -37,5 +38,9 @@ export function shouldNotifyForEvent( return true; } + if (rootId !== null && authoredRootIds.has(rootId)) { + return true; + } + return false; } From bdda4bebad6b0a39821579563990603bba2beade Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 27 May 2026 19:04:53 -0400 Subject: [PATCH 4/7] fix(desktop): thread reply dual-notify, mutable thread muting, All tab filter Thread replies were routed exclusively to the activity feed, losing channel unread badges and dock bounce. The if/else in useLiveChannelUpdates now fires onChannelMessage unconditionally, then additionally fires onThreadReplyNotification for thread replies. Users could not opt out of notifications for threads they authored or participated in. A mutedRootIds denylist (localStorage-persisted, identity-scoped) now lets users mute any thread. The UI collapses to a two-state Follow/Unfollow toggle. Mute precedence: p-tag mentions override mute, mute overrides participation/follow/authorship. The "All" tab in Home excluded pure-activity items; it now includes everything. --- desktop/scripts/check-file-sizes.mjs | 4 +- desktop/src/app/AppShell.tsx | 32 +- .../features/channels/ui/ChannelScreen.tsx | 5 +- .../channels/useLiveChannelUpdates.ts | 5 +- .../features/channels/useUnreadChannels.ts | 67 ++++ desktop/src/features/home/ui/HomeView.tsx | 2 +- .../features/messages/ui/MessageActionBar.tsx | 5 - .../notifications/lib/shouldNotify.test.mjs | 360 ++++++++++++++++++ .../notifications/lib/shouldNotify.ts | 5 + 9 files changed, 465 insertions(+), 20 deletions(-) create mode 100644 desktop/src/features/notifications/lib/shouldNotify.test.mjs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 0aead0d45..32e530a1d 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,12 +34,12 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas) ["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", 865], // 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) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring + isNotifiedForThread combined predicate + threadActivityItems context plumbing + ["src/app/AppShell.tsx", 880], // 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) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring + isNotifiedForThread combined predicate + threadActivityItems context plumbing + mutedRootIds denylist + handleFollowThread/handleUnfollowThread combined handlers ["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", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough ["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context - ["src/features/channels/useUnreadChannels.ts", 650], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + ["src/features/channels/useUnreadChannels.ts", 715], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks ["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 diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index cbfd3d814..a126f2459 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -289,6 +289,9 @@ export function AppShell() { participatedRootIds, authoredRootIds, threadActivityItems, + mutedRootIds, + muteThread, + unmuteThread, } = useUnreadChannels( sidebarChannels, activeChannel, @@ -324,10 +327,27 @@ export function AppShell() { const isNotifiedForThread = React.useCallback( (rootId: string) => - followedRootIds.has(rootId) || - participatedRootIds.has(rootId) || - authoredRootIds.has(rootId), - [followedRootIds, participatedRootIds, authoredRootIds], + !mutedRootIds.has(rootId) && + (followedRootIds.has(rootId) || + participatedRootIds.has(rootId) || + authoredRootIds.has(rootId)), + [followedRootIds, mutedRootIds, participatedRootIds, authoredRootIds], + ); + + const handleFollowThread = React.useCallback( + (rootId: string) => { + followThread(rootId); + unmuteThread(rootId); + }, + [followThread, unmuteThread], + ); + + const handleUnfollowThread = React.useCallback( + (rootId: string) => { + unfollowThread(rootId); + muteThread(rootId); + }, + [unfollowThread, muteThread], ); const createChannelMutation = useCreateChannelMutation(); @@ -629,8 +649,8 @@ export function AppShell() { }, getChannelReadAt, readStateVersion, - followThread, - unfollowThread, + followThread: handleFollowThread, + unfollowThread: handleUnfollowThread, isFollowingThread, isNotifiedForThread, threadActivityItems, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index a415cd58b..4437a54d5 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -99,8 +99,6 @@ export function ChannelScreen({ ); const isNotifiedForCurrentThread = openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false; - const isExplicitlyFollowingCurrentThread = - openThreadHeadId != null ? isFollowingThread(openThreadHeadId) : false; const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( () => new Set(), ); @@ -516,8 +514,7 @@ export function ChannelScreen({ : undefined } onUnfollowThread={ - openThreadHeadId != null && - isExplicitlyFollowingCurrentThread + openThreadHeadId != null && isNotifiedForCurrentThread ? () => unfollowThread(openThreadHeadId) : undefined } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index f24ff86dd..5c2bb2f30 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -33,6 +33,7 @@ export type UseLiveChannelUpdatesOptions = { participatedRootIds?: ReadonlySet; followedRootIds?: ReadonlySet; authoredRootIds?: ReadonlySet; + mutedRootIds?: ReadonlySet; }; const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000; @@ -181,15 +182,15 @@ export function useLiveChannelUpdates( options.participatedRootIds ?? EMPTY_SET, options.followedRootIds ?? EMPTY_SET, options.authoredRootIds ?? EMPTY_SET, + options.mutedRootIds ?? EMPTY_SET, ) ) { + options.onChannelMessage?.(channelId, event); const ref = getThreadReference(event.tags); const isThreadReply = ref.parentId !== null && !isBroadcastReply(event.tags); if (isThreadReply) { options.onThreadReplyNotification?.(channelId, event); - } else { - options.onChannelMessage?.(channelId, event); } } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 41a450ec3..1f58230c6 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -107,6 +107,41 @@ function writeAuthoredToStorage(pubkey: string, rootIds: Set): void { } } +const MUTED_STORAGE_PREFIX = "sprout-thread-muted.v1"; +const MAX_MUTED_ENTRIES = 1000; + +function mutedStorageKey(pubkey: string): string { + return `${MUTED_STORAGE_PREFIX}:${pubkey}`; +} + +function readMutedFromStorage(pubkey: string): Set { + try { + const raw = window.localStorage.getItem(mutedStorageKey(pubkey)); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((id): id is string => typeof id === "string")); + } catch { + return new Set(); + } +} + +function writeMutedToStorage(pubkey: string, rootIds: Set): void { + try { + const arr = [...rootIds]; + const capped = + arr.length > MAX_MUTED_ENTRIES + ? arr.slice(arr.length - MAX_MUTED_ENTRIES) + : arr; + window.localStorage.setItem( + mutedStorageKey(pubkey), + JSON.stringify(capped), + ); + } catch { + // Ignore storage errors (private browsing, quota exceeded). + } +} + export type ThreadActivityItem = { id: string; kind: number; @@ -222,6 +257,10 @@ export function useUnreadChannels( // Used to notify the author when someone replies to their posts. const authoredRootIdsRef = React.useRef(new Set()); + // Root event IDs of threads the user has explicitly muted. Takes precedence + // over participation, follow, and authorship for notification suppression. + const mutedRootIdsRef = React.useRef(new Set()); + // Thread reply events that triggered notifications — surfaced in the Home // activity feed as synthetic FeedItems. const threadActivityRef = React.useRef([]); @@ -250,6 +289,7 @@ export function useUnreadChannels( authoredRootIdsRef.current = pubkey ? readAuthoredFromStorage(pubkey) : new Set(); + mutedRootIdsRef.current = pubkey ? readMutedFromStorage(pubkey) : new Set(); threadActivityRef.current = pubkey ? readActivityFromStorage(pubkey) : []; bumpLatestVersion(); }, [pubkey, relayClient]); @@ -375,6 +415,28 @@ export function useUnreadChannels( [channels, normalizedPubkey], ); + const muteThread = React.useCallback( + (rootId: string) => { + mutedRootIdsRef.current.add(rootId); + if (normalizedPubkey !== null) { + writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current); + } + bumpLatestVersion(); + }, + [normalizedPubkey], + ); + + const unmuteThread = React.useCallback( + (rootId: string) => { + mutedRootIdsRef.current.delete(rootId); + if (normalizedPubkey !== null) { + writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current); + } + bumpLatestVersion(); + }, + [normalizedPubkey], + ); + useLiveChannelUpdates(channels, activeChannelId, { ...liveUpdateOptions, onChannelMessage: handleChannelMessage, @@ -383,6 +445,7 @@ export function useUnreadChannels( participatedRootIds: participatedRootIdsRef.current, followedRootIds: liveUpdateOptions.followedRootIds, authoredRootIds: authoredRootIdsRef.current, + mutedRootIds: mutedRootIdsRef.current, }); // Effect-key the catch-up on the *set* of channel IDs, not the array @@ -492,6 +555,7 @@ export function useUnreadChannels( participatedRootIdsRef.current, options.followedRootIds ?? EMPTY_SET, authoredRootIdsRef.current, + mutedRootIdsRef.current, ) ) { continue; @@ -641,5 +705,8 @@ export function useUnreadChannels( participatedRootIds: participatedRootIdsRef.current as ReadonlySet, authoredRootIds: authoredRootIdsRef.current as ReadonlySet, threadActivityItems: threadActivityRef.current, + mutedRootIds: mutedRootIdsRef.current as ReadonlySet, + muteThread, + unmuteThread, }; } diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0933d453e..31eef646b 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -36,7 +36,7 @@ function matchesInboxFilter( filter: InboxFilter, ) { if (filter === "all") { - return item.categories.some((category) => category !== "activity"); + return true; } return item.categories.includes(filter); diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index fcbe70071..5a25d8ce0 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -169,11 +169,6 @@ function MoreActionsMenu({ )} {isFollowingThread ? "Unfollow thread" : "Follow thread"} - ) : isFollowingThread ? ( - - - Following - ) : null} {hasCopyActions ? ( diff --git a/desktop/src/features/notifications/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs new file mode 100644 index 000000000..17727d1ea --- /dev/null +++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs @@ -0,0 +1,360 @@ +// Run with: node --experimental-strip-types --test shouldNotify.test.mjs +// +// The module under test imports from "@/" path aliases. We register a loader +// hook to resolve those aliases to absolute paths before importing. + +import assert from "node:assert/strict"; +import { register } from "node:module"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import path from "node:path"; +import test from "node:test"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Resolve the desktop/src root (3 levels up from this file's directory) +const srcRoot = path.resolve(__dirname, "../../../"); + +// Inline loader hook data sent to the hook thread via the MessageChannel +const loaderSource = ` +export function resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@/")) { + const srcRoot = ${JSON.stringify(srcRoot)}; + // Map @/foo/bar → /foo/bar.ts + const withoutAlias = specifier.slice(2); // drop "@/" + const resolved = srcRoot + "/" + withoutAlias + ".ts"; + return nextResolve(resolved, context); + } + return nextResolve(specifier, context); +} +`; + +// Write the loader to a temp file so we can register it +import { writeFileSync, mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; + +const tmpDir = path.join(os.tmpdir(), `sprout-test-loader-${process.pid}`); +mkdirSync(tmpDir, { recursive: true }); +const loaderPath = path.join(tmpDir, "alias-loader.mjs"); +writeFileSync(loaderPath, loaderSource); + +register(pathToFileURL(loaderPath).href); + +// Now dynamically import the module under test so the loader is active +const { shouldNotifyForEvent } = await import("./shouldNotify.ts"); + +// Clean up the temp loader +rmSync(tmpDir, { recursive: true, force: true }); + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const PUBKEY = "a".repeat(64); +const OTHER_PUBKEY = "b".repeat(64); +const ROOT_ID = `root-${"0".repeat(59)}`; +const PARENT_ID = `parent-${"0".repeat(57)}`; + +const EMPTY = new Set(); + +/** Returns a minimal RelayEvent with the given tags. */ +function makeEvent(tags = [], overrides = {}) { + return { + id: `event-${"0".repeat(59)}`, + pubkey: OTHER_PUBKEY, + created_at: 1700000000, + kind: 9, + tags, + content: "hello", + sig: "s".repeat(128), + ...overrides, + }; +} + +const rootTag = (id) => ["e", id, "", "root"]; +const replyTag = (id) => ["e", id, "", "reply"]; +const pTag = (pubkey) => ["p", pubkey]; +const broadcastTag = () => ["broadcast", "1"]; + +// ── 1. Top-level message → always true ────────────────────────────────────── + +test("top-level message (no e-tags) notifies", () => { + assert.equal( + shouldNotifyForEvent(makeEvent([]), PUBKEY, EMPTY, EMPTY, EMPTY), + true, + ); +}); + +test("top-level message with unrelated p-tag notifies", () => { + assert.equal( + shouldNotifyForEvent( + makeEvent([pTag(OTHER_PUBKEY)]), + PUBKEY, + EMPTY, + EMPTY, + EMPTY, + ), + true, + ); +}); + +// ── 2. Broadcast reply → always true ───────────────────────────────────────── + +test("broadcast reply to unrelated thread notifies", () => { + const event = makeEvent([replyTag(ROOT_ID), broadcastTag()]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true); +}); + +test("broadcast reply with root+reply tags notifies", () => { + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + broadcastTag(), + ]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true); +}); + +// ── 3. Thread reply with p-tag mention → always true ───────────────────────── + +test("thread reply with p-tag mention of currentPubkey notifies", () => { + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + pTag(PUBKEY), + ]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true); +}); + +test("p-tag mention matching is case-insensitive", () => { + const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY.toUpperCase())]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true); +}); + +test("p-tag mention of a different pubkey does not trigger mention path", () => { + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + pTag(OTHER_PUBKEY), + ]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false); +}); + +// ── 4. Thread reply to participated thread → true ──────────────────────────── + +test("thread reply to participated thread notifies", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal( + shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY), + true, + ); +}); + +test("shallow thread reply (root===parent) to participated thread notifies", () => { + // Single reply tag: rootId falls back to parentId == ROOT_ID + const event = makeEvent([replyTag(ROOT_ID)]); + assert.equal( + shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY), + true, + ); +}); + +// ── 5. Thread reply to followed thread → true ──────────────────────────────── + +test("thread reply to followed thread notifies", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal( + shouldNotifyForEvent(event, PUBKEY, EMPTY, new Set([ROOT_ID]), EMPTY), + true, + ); +}); + +// ── 6. Thread reply to authored thread → true ──────────────────────────────── + +test("thread reply to authored thread notifies", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal( + shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, new Set([ROOT_ID])), + true, + ); +}); + +// ── 7. Thread reply to unrelated thread → false ─────────────────────────────── + +test("thread reply to unrelated thread does not notify", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false); +}); + +// ── 8. Muted thread suppresses participated ─────────────────────────────────── + +test("muted thread reply suppresses participated", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal( + shouldNotifyForEvent( + event, + PUBKEY, + new Set([ROOT_ID]), + EMPTY, + EMPTY, + new Set([ROOT_ID]), + ), + false, + ); +}); + +// ── 9. Muted thread suppresses followed ────────────────────────────────────── + +test("muted thread reply suppresses followed", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal( + shouldNotifyForEvent( + event, + PUBKEY, + EMPTY, + new Set([ROOT_ID]), + EMPTY, + new Set([ROOT_ID]), + ), + false, + ); +}); + +// ── 10. Muted thread suppresses authored ───────────────────────────────────── + +test("muted thread reply suppresses authored", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal( + shouldNotifyForEvent( + event, + PUBKEY, + EMPTY, + EMPTY, + new Set([ROOT_ID]), + new Set([ROOT_ID]), + ), + false, + ); +}); + +// ── 11. Muted thread with p-tag mention still notifies ─────────────────────── + +test("muted thread reply still notifies when currentPubkey is mentioned via p-tag", () => { + // p-tag check fires BEFORE the mute gate → mention always wins + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + pTag(PUBKEY), + ]); + assert.equal( + shouldNotifyForEvent( + event, + PUBKEY, + EMPTY, + EMPTY, + EMPTY, + new Set([ROOT_ID]), + ), + true, + ); +}); + +// ── 12. Muted top-level message still notifies ─────────────────────────────── + +test("muted rootId does not suppress a top-level (non-reply) message", () => { + // parentId is null for top-level → function returns true before reaching mute check + const event = makeEvent([]); + assert.equal( + shouldNotifyForEvent( + event, + PUBKEY, + EMPTY, + EMPTY, + EMPTY, + new Set([ROOT_ID]), + ), + true, + ); +}); + +// ── 13. Default parameter — mutedRootIds omitted ───────────────────────────── + +test("omitting mutedRootIds parameter defaults to empty set and still notifies participated", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + // No sixth argument — should not throw and should notify for participated thread + assert.equal( + shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY), + true, + ); +}); + +test("omitting mutedRootIds for unrelated thread returns false without throwing", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false); +}); + +// ── 14. Muted shallow thread reply (single replyTag, no rootTag) ──────────── + +test("muted shallow thread reply (rootId falls back to parentId) is suppressed", () => { + const event = makeEvent([replyTag(ROOT_ID)]); + assert.equal( + shouldNotifyForEvent( + event, + PUBKEY, + new Set([ROOT_ID]), + EMPTY, + EMPTY, + new Set([ROOT_ID]), + ), + false, + ); +}); + +// ── 15. Muted thread + broadcast reply still notifies ─────────────────────── + +test("broadcast reply on a muted thread still notifies (broadcast overrides mute)", () => { + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + broadcastTag(), + ]); + assert.equal( + shouldNotifyForEvent( + event, + PUBKEY, + EMPTY, + EMPTY, + EMPTY, + new Set([ROOT_ID]), + ), + true, + ); +}); + +// ── 16. Empty currentPubkey with p-tag present ────────────────────────────── + +test("empty currentPubkey skips p-tag check — muted thread is suppressed", () => { + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + pTag(PUBKEY), + ]); + assert.equal( + shouldNotifyForEvent( + event, + "", + new Set([ROOT_ID]), + EMPTY, + EMPTY, + new Set([ROOT_ID]), + ), + false, + ); +}); + +test("empty currentPubkey with participated thread still notifies (no mute)", () => { + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + pTag(PUBKEY), + ]); + assert.equal( + shouldNotifyForEvent(event, "", new Set([ROOT_ID]), EMPTY, EMPTY), + true, + ); +}); diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 9e663d9ee..5c32b0d2e 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -10,6 +10,7 @@ export function shouldNotifyForEvent( participatedRootIds: ReadonlySet, followedRootIds: ReadonlySet, authoredRootIds: ReadonlySet, + mutedRootIds: ReadonlySet = new Set(), ): boolean { const { parentId, rootId } = getThreadReference(event.tags); @@ -30,6 +31,10 @@ export function shouldNotifyForEvent( return true; } + if (rootId !== null && mutedRootIds.has(rootId)) { + return false; + } + if (rootId !== null && participatedRootIds.has(rootId)) { return true; } From 44df0dd03e8b3586d4198276dfc6b1b8e16006f0 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 27 May 2026 19:35:17 -0400 Subject: [PATCH 5/7] fix(desktop): remove activity items from home badge seen-set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge hook's currentFeedItems included feed.feed.activity, which inflated the seen-set and badge count. Activity items from the relay API are always empty in production (Rust returns Vec::new()), but the E2E mock feed seeds them, breaking two tests. Thread activity items surface via HomeScreen's augmented feed for display only — they should not participate in the badge/seen-set mechanism. --- desktop/src/features/notifications/hooks.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 6fb699eff..3637ee984 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -282,14 +282,7 @@ export function useHomeFeedNotificationState( readStoredSeenFeedIds(normalizedPubkey), ); const currentFeedItems = React.useMemo( - () => - feed - ? [ - ...feed.feed.mentions, - ...feed.feed.needsAction, - ...feed.feed.activity, - ] - : [], + () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), [feed], ); const currentFeedIds = React.useMemo( From be6ee1c66cf8ae3b743276064ced26eafb1846e2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 27 May 2026 19:58:56 -0400 Subject: [PATCH 6/7] fix(desktop): exclude activity items from desktop notification seen-set seed collectHomeAlertItems was including feed.feed.activity, causing the first-load seed in useFeedDesktopNotifications to write 4 IDs to localStorage instead of the expected 2 (mentions + needsAction only). --- desktop/src/features/notifications/lib/feed.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/desktop/src/features/notifications/lib/feed.ts b/desktop/src/features/notifications/lib/feed.ts index 6285daca1..3529053af 100644 --- a/desktop/src/features/notifications/lib/feed.ts +++ b/desktop/src/features/notifications/lib/feed.ts @@ -44,11 +44,7 @@ export function notificationBody(item: FeedItem) { } export function collectHomeAlertItems(feed: HomeFeedResponse) { - return [ - ...feed.feed.mentions, - ...feed.feed.needsAction, - ...feed.feed.activity, - ]; + return [...feed.feed.mentions, ...feed.feed.needsAction]; } export function eligibleFeedNotificationItems( From 95c356ce43844718b6b7012ebf9b2a2e76a941a2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 28 May 2026 15:08:45 -0400 Subject: [PATCH 7/7] refactor(desktop): shared test loader for @/ path alias resolution node:test with --experimental-strip-types doesn't resolve tsconfig path aliases. Previously shouldNotify.test.mjs had a 40-line inline loader hack; threadPanel.test.mjs broke when threadPanel.ts gained @/ imports. Centralizes alias resolution into test-loader-hooks.mjs registered via --import in the test script. --- desktop/package.json | 2 +- .../notifications/lib/shouldNotify.test.mjs | 43 +------------------ desktop/test-loader-hooks.mjs | 15 +++++++ desktop/test-loader.mjs | 3 ++ 4 files changed, 20 insertions(+), 43 deletions(-) create mode 100644 desktop/test-loader-hooks.mjs create mode 100644 desktop/test-loader.mjs diff --git a/desktop/package.json b/desktop/package.json index f55943c80..1ba20bdb2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,7 +11,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes", "format": "biome format --write .", - "test": "node --test 'src/**/*.test.mjs'", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build && playwright test", diff --git a/desktop/src/features/notifications/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs index 17727d1ea..e099f6733 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs @@ -1,48 +1,7 @@ -// Run with: node --experimental-strip-types --test shouldNotify.test.mjs -// -// The module under test imports from "@/" path aliases. We register a loader -// hook to resolve those aliases to absolute paths before importing. - import assert from "node:assert/strict"; -import { register } from "node:module"; -import { pathToFileURL, fileURLToPath } from "node:url"; -import path from "node:path"; import test from "node:test"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Resolve the desktop/src root (3 levels up from this file's directory) -const srcRoot = path.resolve(__dirname, "../../../"); - -// Inline loader hook data sent to the hook thread via the MessageChannel -const loaderSource = ` -export function resolve(specifier, context, nextResolve) { - if (specifier.startsWith("@/")) { - const srcRoot = ${JSON.stringify(srcRoot)}; - // Map @/foo/bar → /foo/bar.ts - const withoutAlias = specifier.slice(2); // drop "@/" - const resolved = srcRoot + "/" + withoutAlias + ".ts"; - return nextResolve(resolved, context); - } - return nextResolve(specifier, context); -} -`; - -// Write the loader to a temp file so we can register it -import { writeFileSync, mkdirSync, rmSync } from "node:fs"; -import os from "node:os"; - -const tmpDir = path.join(os.tmpdir(), `sprout-test-loader-${process.pid}`); -mkdirSync(tmpDir, { recursive: true }); -const loaderPath = path.join(tmpDir, "alias-loader.mjs"); -writeFileSync(loaderPath, loaderSource); - -register(pathToFileURL(loaderPath).href); - -// Now dynamically import the module under test so the loader is active -const { shouldNotifyForEvent } = await import("./shouldNotify.ts"); - -// Clean up the temp loader -rmSync(tmpDir, { recursive: true, force: true }); +import { shouldNotifyForEvent } from "./shouldNotify.ts"; // ── Fixtures ───────────────────────────────────────────────────────────────── diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs new file mode 100644 index 000000000..a9e17ad83 --- /dev/null +++ b/desktop/test-loader-hooks.mjs @@ -0,0 +1,15 @@ +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const srcRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "src", +); + +export function resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@/")) { + const resolved = `${srcRoot}/${specifier.slice(2)}.ts`; + return nextResolve(resolved, context); + } + return nextResolve(specifier, context); +} diff --git a/desktop/test-loader.mjs b/desktop/test-loader.mjs new file mode 100644 index 000000000..0d846d862 --- /dev/null +++ b/desktop/test-loader.mjs @@ -0,0 +1,3 @@ +import { register } from "node:module"; + +register("./test-loader-hooks.mjs", import.meta.url);