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/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 91e8c6359..32e530a1d 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", 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", 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", 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/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 a51c2011b..a126f2459 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, @@ -278,6 +286,12 @@ export function AppShell() { unreadChannelIds, getEffectiveTimestamp: getChannelReadAt, readStateVersion, + participatedRootIds, + authoredRootIds, + threadActivityItems, + mutedRootIds, + muteThread, + unmuteThread, } = useUnreadChannels( sidebarChannels, activeChannel, @@ -291,6 +305,7 @@ export function AppShell() { onChannelMessage: handleChannelNotification, onDmMessage: handleDmNotification, onLiveMention: refetchHomeFeedOnLiveMention, + followedRootIds, }, ); @@ -310,6 +325,31 @@ export function AppShell() { feedProfilesQuery.data?.profiles, ); + const isNotifiedForThread = React.useCallback( + (rootId: string) => + !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(); const createForumMutation = useCreateChannelMutation(); const { applyCanvas, applyAgents } = useApplyTemplate(); @@ -609,6 +649,11 @@ export function AppShell() { }, getChannelReadAt, readStateVersion, + followThread: handleFollowThread, + unfollowThread: handleUnfollowThread, + isFollowingThread, + isNotifiedForThread, + threadActivityItems, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index f61df09e6..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; @@ -18,6 +19,11 @@ 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; + isNotifiedForThread: (rootId: string) => boolean; + threadActivityItems: ThreadActivityItem[]; }; const AppShellContext = React.createContext({ @@ -27,6 +33,11 @@ const AppShellContext = React.createContext({ openChannelManagement: () => {}, getChannelReadAt: () => null, readStateVersion: 0, + followThread: () => {}, + unfollowThread: () => {}, + isFollowingThread: () => false, + isNotifiedForThread: () => false, + threadActivityItems: [], }); export function AppShellProvider({ diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index fac3afc33..e4979cc3d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -90,6 +90,12 @@ type ChannelPaneProps = { threadScrollTargetId: string | null; targetMessageId: string | null; typingPubkeys: string[]; + 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({ @@ -103,6 +109,9 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder, hasOlderMessages, isFetchingOlder, + followThreadById, + isFollowingThread, + isFollowingThreadById, isJoining = false, isSending, isTimelineLoading, @@ -115,6 +124,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete, onEdit, onEditSave, + onFollowThread, onMarkUnread, onExpandThreadReplies, onJoinChannel, @@ -127,6 +137,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onThreadScrollTargetResolved, onTargetReached, onToggleReaction, + onUnfollowThread, + unfollowThreadById, personaLookup, profiles, openThreadHeadId, @@ -247,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." @@ -369,6 +384,7 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey={currentPubkey} disabled={isComposerDisabled} editTarget={threadEditTarget} + isFollowingThread={isFollowingThread} isSending={isSending} onCancelEdit={onCancelEdit} onCancelReply={onCancelThreadReply} @@ -376,12 +392,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..4437a54d5 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -81,8 +81,15 @@ export function ChannelScreen({ targetMessageEvent, targetMessageId, }: ChannelScreenProps) { - const { markChannelRead, markChannelUnread, openChannelManagement } = - useAppShell(); + const { + markChannelRead, + markChannelUnread, + openChannelManagement, + followThread, + unfollowThread, + isFollowingThread, + isNotifiedForThread, + } = useAppShell(); const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< string | null >(null); @@ -90,6 +97,8 @@ export function ChannelScreen({ const [openThreadHeadId, setOpenThreadHeadId] = React.useState( null, ); + const isNotifiedForCurrentThread = + openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false; const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( () => new Set(), ); @@ -490,11 +499,25 @@ export function ChannelScreen({ } : null } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isFollowingThread={isNotifiedForCurrentThread} isSending={sendMessageMutation.isPending} isTimelineLoading={isTimelineLoading} messages={timelineMessages} onCancelEdit={handleCancelEdit} onCancelThreadReply={handleCancelThreadReply} + onFollowThread={ + openThreadHeadId != null && !isNotifiedForCurrentThread + ? () => followThread(openThreadHeadId) + : undefined + } + onUnfollowThread={ + openThreadHeadId != null && isNotifiedForCurrentThread + ? () => unfollowThread(openThreadHeadId) + : undefined + } onCloseAgentSession={handleCloseAgentSession} onCloseThread={handleCloseThread} onDelete={ 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 c330ad2e4..5c2bb2f30 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -4,7 +4,12 @@ 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 { CHANNEL_EVENT_KINDS, @@ -23,6 +28,12 @@ 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; + mutedRootIds?: ReadonlySet; }; const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000; @@ -32,6 +43,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); +export const EMPTY_SET: ReadonlySet = new Set(); + function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { return ( currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey @@ -144,6 +157,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,9 +175,23 @@ 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.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); + } } // 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 e6ab70954..1f58230c6 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -1,9 +1,15 @@ import * as React from "react"; import { + EMPTY_SET, useLiveChannelUpdates, type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; import { useReadState } from "@/features/channels/readState/useReadState"; +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"; import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; @@ -20,6 +26,175 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; +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). + } +} + +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). + } +} + +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; + 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; @@ -74,6 +249,22 @@ 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()); + + // 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()); + + // 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([]); + // 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 +283,14 @@ export function useUnreadChannels( latestByChannelRef.current = new Map(); forcedUnreadRef.current = new Set(); caughtUpChannelsRef.current = new Set(); + participatedRootIdsRef.current = pubkey + ? readParticipationFromStorage(pubkey) + : new Set(); + authoredRootIdsRef.current = pubkey + ? readAuthoredFromStorage(pubkey) + : new Set(); + mutedRootIdsRef.current = pubkey ? readMutedFromStorage(pubkey) : new Set(); + threadActivityRef.current = pubkey ? readActivityFromStorage(pubkey) : []; bumpLatestVersion(); }, [pubkey, relayClient]); @@ -165,9 +364,88 @@ export function useUnreadChannels( [callerOnChannelMessage], ); + 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, + ); + } + } 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], + ); + + 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, + onThreadReplyNotification: handleThreadReplyNotification, + onSelfChannelMessage: handleSelfChannelMessage, + 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 @@ -184,6 +462,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; @@ -205,7 +484,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( @@ -224,7 +508,38 @@ export function useUnreadChannels( limit: CATCH_UP_LIMIT, }); + // 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 && + event.pubkey.toLowerCase() === normalizedPubkey + ) { + const ref = getThreadReference(event.tags); + if (ref.rootId !== null) { + participatedRootIdsRef.current.add(ref.rootId); + } else { + authoredRootIdsRef.current.add(event.id); + } + } + } + + if (normalizedPubkey !== null) { + writeParticipationToStorage( + normalizedPubkey, + participatedRootIdsRef.current, + ); + writeAuthoredToStorage( + normalizedPubkey, + authoredRootIdsRef.current, + ); + } + + // 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 && @@ -233,12 +548,37 @@ export function useUnreadChannels( continue; } if (readAt !== null && event.created_at <= readAt) continue; + if ( + !shouldNotifyForEvent( + event, + normalizedPubkey ?? "", + participatedRootIdsRef.current, + options.followedRootIds ?? EMPTY_SET, + authoredRootIdsRef.current, + mutedRootIdsRef.current, + ) + ) { + continue; + } 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 @@ -249,12 +589,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) { @@ -262,6 +604,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(); }); @@ -342,5 +702,11 @@ export function useUnreadChannels( // should include in memo deps. getEffectiveTimestamp, readStateVersion, + 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/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/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/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 new file mode 100644 index 000000000..b78b6b358 --- /dev/null +++ b/desktop/src/features/messages/lib/useThreadFollows.ts @@ -0,0 +1,140 @@ +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[]): boolean { + try { + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(entries)); + return true; + } catch { + return false; + } +} + +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]); + + 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], + ); + + 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() }]); + if (!writeToStorage(pubkey, next)) { + return prev; + } + return next; + }); + }, + [pubkey], + ); + + const unfollowThread = React.useCallback( + (rootId: string) => { + if (!pubkey) { + return; + } + setEntries((prev) => { + const next = prev.filter((e) => e.rootId !== rootId); + if (!writeToStorage(pubkey, next)) { + return prev; + } + 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/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} /> ["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 new file mode 100644 index 000000000..5c32b0d2e --- /dev/null +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -0,0 +1,51 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; + +export function shouldNotifyForEvent( + event: RelayEvent, + currentPubkey: string, + participatedRootIds: ReadonlySet, + followedRootIds: ReadonlySet, + authoredRootIds: ReadonlySet, + mutedRootIds: ReadonlySet = new Set(), +): boolean { + const { parentId, rootId } = getThreadReference(event.tags); + + if (parentId === null) { + return true; + } + + if (isBroadcastReply(event.tags)) { + return true; + } + + if ( + currentPubkey.length > 0 && + event.tags.some( + (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey, + ) + ) { + return true; + } + + if (rootId !== null && mutedRootIds.has(rootId)) { + return false; + } + + if (rootId !== null && participatedRootIds.has(rootId)) { + return true; + } + + if (rootId !== null && followedRootIds.has(rootId)) { + return true; + } + + if (rootId !== null && authoredRootIds.has(rootId)) { + return true; + } + + return false; +} 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);