From f6387f9aeddd590fb8769a0487c442198e7367ff Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 18 Jun 2026 17:18:03 +0100 Subject: [PATCH 01/31] Improve inbox thread updates --- desktop/src/app/AppShell.tsx | 86 +- desktop/src/app/AppShellContext.tsx | 12 + .../features/channels/ui/ChannelScreen.tsx | 13 +- .../channels/unreadReadMarker.test.mjs | 118 +-- .../channels/useLiveChannelUpdates.ts | 63 +- .../features/channels/useUnreadChannels.ts | 897 +++++++++++++++--- desktop/src/features/home/lib/inbox.test.mjs | 150 +++ desktop/src/features/home/lib/inbox.ts | 117 ++- .../home/lib/inboxViewHelpers.test.mjs | 122 +++ .../src/features/home/lib/inboxViewHelpers.ts | 60 +- desktop/src/features/home/ui/HomeView.tsx | 32 +- .../src/features/home/ui/InboxDetailPane.tsx | 10 +- .../src/features/home/ui/InboxListPane.tsx | 321 ++++--- desktop/src/features/home/useFeedItemState.ts | 28 +- .../features/home/useHomeInboxReadState.ts | 81 +- .../features/home/useInboxThreadContext.ts | 70 +- .../home/useResizableInboxListWidth.ts | 2 +- .../src/features/messages/lib/threading.ts | 5 + desktop/src/features/notifications/hooks.ts | 46 +- desktop/src/shared/api/relayClientShared.ts | 1 + desktop/src/shared/styles/globals.css | 33 + 21 files changed, 1797 insertions(+), 470 deletions(-) create mode 100644 desktop/src/features/home/lib/inbox.test.mjs diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index cca89f471..b19c7fd10 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -28,6 +28,7 @@ import { } from "@/features/channels/hooks"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications"; +import { useFeedItemState } from "@/features/home/useFeedItemState"; import { getThreadReference } from "@/features/messages/lib/threading"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import { useThreadFollows } from "@/features/messages/lib/useThreadFollows"; @@ -77,16 +78,24 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { joinChannel } from "@/shared/api/tauri"; -import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; +import type { + Channel, + FeedItem, + RelayEvent, + SearchHit, +} from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; +import { KIND_APPROVAL_REQUEST, KIND_REMINDER } from "@/shared/constants/kinds"; import { ConnectionBanner } from "@/shared/ui/ConnectionBanner"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; +const HOME_FEED_ACTION_KINDS = [KIND_APPROVAL_REQUEST, KIND_REMINDER] as const; + const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -163,13 +172,53 @@ export function AppShell() { const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey); const { feedProfilesQuery, homeFeedQuery, notificationSettings } = useHomeFeedNotifications(identityQuery.data?.pubkey); + const feedItemState = useFeedItemState(identityQuery.data?.pubkey); useReminderNotifications( identityQuery.data?.pubkey, notificationSettings.settings, ); - const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { + const refetchHomeFeedFromLiveSignal = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); + React.useEffect(() => { + const pubkey = identityQuery.data?.pubkey?.trim().toLowerCase() ?? ""; + if (!pubkey) { + return; + } + + let isCancelled = false; + let dispose: (() => Promise) | null = null; + + void relayClient + .subscribeLive( + { + kinds: [...HOME_FEED_ACTION_KINDS], + "#p": [pubkey], + limit: 50, + since: Math.floor(Date.now() / 1_000), + }, + () => { + refetchHomeFeedFromLiveSignal(); + }, + ) + .then((nextDispose) => { + if (isCancelled) { + void nextDispose().catch(() => {}); + return; + } + dispose = nextDispose; + }) + .catch((error) => { + console.error("Failed to subscribe to live home feed actions", error); + }); + + return () => { + isCancelled = true; + if (dispose) { + void dispose().catch(() => {}); + } + }; + }, [identityQuery.data?.pubkey]); const handleChannelNotification = React.useEffectEvent( (_channelId: string, _event: RelayEvent) => { if (!notificationSettings.settings.desktopEnabled) return; @@ -307,6 +356,7 @@ export function AppShell() { markChannelUnread, unreadChannelIds, highPriorityUnreadChannelIds, + unreadChannelNotificationCount, getEffectiveTimestamp: getChannelReadAt, readStateVersion, setContextParentResolver, @@ -325,7 +375,7 @@ export function AppShell() { notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, onChannelMessage: handleChannelNotification, onDmMessage: handleDmNotification, - onLiveMention: refetchHomeFeedOnLiveMention, + onLiveMention: refetchHomeFeedFromLiveSignal, onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, followedRootIds, }); @@ -344,6 +394,22 @@ export function AppShell() { }, [markChannelRead], ); + const threadActivityFeedItems = React.useMemo( + () => + threadActivityItems.map((item) => ({ + 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, + })), + [threadActivityItems], + ); // Badge count is computed here (rather than inside useHomeFeedNotifications) // so it can consume the NIP-RS read-state lifted from the single @@ -362,6 +428,9 @@ export function AppShell() { highPriorityUnreadChannelIds, feedProfilesQuery.data?.profiles, mutedChannelIds, + feedItemState.unreadSet, + threadActivityFeedItems, + getThreadReadAt, ); const isNotifiedForThread = React.useCallback( @@ -542,19 +611,13 @@ export function AppShell() { React.useEffect(() => { const numericCount = - highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority; + unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; if (numericCount > 0) { void setDesktopAppBadge({ kind: "count", count: numericCount }); - } else if (unreadChannelIds.size > 0) { - void setDesktopAppBadge({ kind: "dot" }); } else { void setDesktopAppBadge({ kind: "none" }); } - }, [ - homeBadgeCountExcludingHighPriority, - highPriorityUnreadChannelIds.size, - unreadChannelIds.size, - ]); + }, [homeBadgeCountExcludingHighPriority, unreadChannelNotificationCount]); // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); @@ -715,6 +778,7 @@ export function AppShell() { setTopbarSearchHidden, setTopbarSearchLoading, threadActivityItems, + feedItemState, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index bd27e3019..44e415d17 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -1,6 +1,9 @@ import * as React from "react"; import type { ContextParentResolver } from "@/features/channels/readState/readStateManager"; import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; +import type { FeedItemState } from "@/features/home/useFeedItemState"; + +const EMPTY_SET = new Set(); type AppShellContextValue = { markAllChannelsRead: () => void; @@ -34,6 +37,7 @@ type AppShellContextValue = { setTopbarSearchHidden: (hidden: boolean) => void; setTopbarSearchLoading: (loading: boolean) => void; threadActivityItems: ThreadActivityItem[]; + feedItemState: FeedItemState; }; const AppShellContext = React.createContext({ @@ -54,6 +58,14 @@ const AppShellContext = React.createContext({ setTopbarSearchHidden: () => {}, setTopbarSearchLoading: () => {}, threadActivityItems: [], + feedItemState: { + doneSet: EMPTY_SET, + markDone: () => {}, + markUnread: () => {}, + undoDone: () => {}, + undoUnread: () => {}, + unreadSet: EMPTY_SET, + }, }); export function AppShellProvider({ diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index d3b21fa63..857adb0ad 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -159,9 +159,9 @@ export function ChannelScreen({ // No `lastMessageAt` fallback: that timestamp is reply-inclusive (the backend // takes MAX(created_at) over kind-9 events without a parent filter), so using // it when the window has no top-level message would advance the channel - // marker past an unread reply and clear its thread/sidebar unread — the exact - // regression Fix A prevents. null suppresses the marker advance (markChannelRead - // early-returns on markAt === null) until a real top-level position is known. + // marker past an unread reply and clear its thread unread. null suppresses + // the marker advance (markChannelRead early-returns on markAt === null) until + // a real top-level position is known. const activeReadAt = latestActiveMessage ? new Date(latestActiveMessage.created_at * 1_000).toISOString() : null; @@ -170,10 +170,9 @@ export function ChannelScreen({ return; } // Passive channel-open: advance the marker to the newest top-level message - // only (NIP-RS Option 1). `topLevelOnly` stops the read-state layer folding - // in observed thread replies, so opening a channel clears the timeline but - // leaves thread badges intact until each thread is opened — and leaves the - // channel's sidebar dot lit (the reply is still unread for the channel). + // only (NIP-RS Option 1). Opening a channel clears the main timeline while + // leaving thread badges and Home inbox thread activity intact until each + // thread itself is read. markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true }); }, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]); // Install the NIP-RS parent resolver: every `thread:` context evaluated diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index ebb99bb85..fa45fb058 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -2,11 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts"; -import { - buildChannelThreadRoots, - channelUnreadFrontier, - resolveChannelReadMarker, -} from "./useUnreadChannels.ts"; +import { isThreadReply } from "../messages/lib/threading.ts"; +import { resolveChannelReadMarker } from "./useUnreadChannels.ts"; function topLevel(id, createdAt) { return { id, createdAt, author: "a", time: "", body: "", depth: 0 }; @@ -78,104 +75,27 @@ test("resolveChannelReadMarker_noCallerNoObserved_returnsNull", () => { assert.equal(result.clearObserved, false); }); -// --- Fix 2: sidebar dot folds per-thread read markers into the channel frontier --- +// --- Sidebar dot routing: only main-channel activity belongs to the channel dot --- -function replyItem(channelId, rootId) { - return { - id: `${channelId}:${rootId}:${Math.random()}`, - channelId, - tags: [["e", rootId, "", "root"]], - }; -} - -// rootId for these fixtures is the "root"-marked e-tag. -const getRootId = (tags) => - tags.find((t) => t[0] === "e" && t[3] === "root")?.[1] ?? null; - -test("buildChannelThreadRoots_groupsRootsByChannel", () => { - const items = [ - replyItem("chan-a", "root-1"), - replyItem("chan-a", "root-1"), // dedup within a channel - replyItem("chan-a", "root-2"), - replyItem("chan-b", "root-3"), - ]; - const map = buildChannelThreadRoots(items, getRootId); - - assert.deepEqual([...(map.get("chan-a") ?? [])].sort(), ["root-1", "root-2"]); - assert.deepEqual([...(map.get("chan-b") ?? [])], ["root-3"]); - assert.equal(map.has("chan-c"), false); -}); - -test("buildChannelThreadRoots_skipsItemsWithNoRoot", () => { - const items = [{ id: "x", channelId: "chan-a", tags: [["p", "someone"]] }]; - const map = buildChannelThreadRoots(items, getRootId); - - assert.equal(map.size, 0); -}); - -test("channelUnreadFrontier_unopenedThreadReply_dotPersists", () => { - // Channel's only unread is a thread reply at t=500. The channel marker sits - // at the newest TOP-LEVEL message (t=300, Option-1) and the thread has never - // been opened (own marker null). Folded frontier stays at 300 < 500 → unread. - const channelMarker = 300; - const threadRoots = new Set(["root-1"]); - const getThreadOwnMarker = () => null; // never opened - - const frontier = channelUnreadFrontier( - channelMarker, - threadRoots, - getThreadOwnMarker, - ); - - const latest = 500; // the thread reply timestamp - assert.equal(frontier, 300); - assert.equal(latest > frontier, true); // dot present (Will-accepted) -}); - -test("channelUnreadFrontier_openedThreadReply_dotClears", () => { - // Same channel, but the user opened the thread: markThreadRead advanced - // thread:root-1's OWN marker to 500 (the reply). Folded frontier rises to - // 500 ≥ latest → dot clears, even though the channel marker is still 300. - const channelMarker = 300; - const threadRoots = new Set(["root-1"]); - const getThreadOwnMarker = (rootId) => (rootId === "root-1" ? 500 : null); - - const frontier = channelUnreadFrontier( - channelMarker, - threadRoots, - getThreadOwnMarker, - ); - - const latest = 500; - assert.equal(frontier, 500); - assert.equal(latest > frontier, false); // dot cleared -}); - -test("channelUnreadFrontier_noThreadRoots_usesChannelMarker", () => { - // No thread roots observed (or evicted) → channel marker governs unchanged. +test("isThreadReply detects normal threaded replies", () => { assert.equal( - channelUnreadFrontier(300, undefined, () => null), - 300, - ); - assert.equal( - channelUnreadFrontier(null, undefined, () => null), - null, + isThreadReply([ + ["h", "chan-a"], + ["e", "root-1", "", "root"], + ["e", "parent-1", "", "reply"], + ]), + true, ); }); -test("channelUnreadFrontier_nullChannelMarker_threadMarkerGoverns", () => { - // Channel never marked read but a thread was opened: the thread's own marker - // becomes the frontier rather than crashing on the null channel marker. - const frontier = channelUnreadFrontier(null, new Set(["root-1"]), () => 500); - assert.equal(frontier, 500); -}); - -test("channelUnreadFrontier_takesMaxAcrossMultipleThreads", () => { - // Two opened threads with different markers → the highest wins. - const frontier = channelUnreadFrontier( - 100, - new Set(["root-1", "root-2"]), - (rootId) => (rootId === "root-1" ? 400 : 700), +test("isThreadReply treats top-level and broadcast replies as channel activity", () => { + assert.equal(isThreadReply([["h", "chan-a"]]), false); + assert.equal( + isThreadReply([ + ["h", "chan-a"], + ["broadcast", "1"], + ["e", "root-1", "", "reply"], + ]), + false, ); - assert.equal(frontier, 700); }); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 14950e9e7..fca19320c 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -6,8 +6,7 @@ import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { getChannelIdFromTags, - getThreadReference, - isBroadcastReply, + isThreadReply, } from "@/features/messages/lib/threading"; import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import { relayClient } from "@/shared/api/relayClient"; @@ -29,13 +28,23 @@ export type UseLiveChannelUpdatesOptions = { onDmMessage?: (event: RelayEvent, channel: Channel) => void; onLiveMention?: () => void; /** - * Fired for live "new content" events in a member channel (chat messages, - * forum posts/comments) authored by someone other than the current user. + * Fired for live main-channel "new content" events in a member channel + * authored by someone other than the current user. Thread replies are + * routed through onThreadReplyNotification instead. * Used to drive the in-session "latest message at" map that powers sidebar * unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set. */ onChannelMessage?: (channelId: string, event: RelayEvent) => void; + /** + * Fired for thread replies that should be surfaced as Home inbox activity. + */ onThreadReplyNotification?: (channelId: string, event: RelayEvent) => void; + /** + * Fired for external thread replies that do not match the locally-known + * interest sets. Callers can perform an async backfill and then decide + * whether to surface the event. + */ + onThreadReplyCandidate?: (channelId: string, event: RelayEvent) => void; /** * Fired for replies in threads the user authored, participated in, or * follows (non-DM channels only — the DM path owns those). Follows the DM @@ -196,27 +205,37 @@ export function useLiveChannelUpdates( // and to events authored by someone other than the current user — your // own outgoing messages should never make a channel unread, and // reactions / edits / system messages aren't "new content". - if ( + const isExternalTriggerEvent = UNREAD_TRIGGER_KINDS.has(event.kind) && (normalizedCurrentPubkey.length === 0 || - event.pubkey.toLowerCase() !== normalizedCurrentPubkey) && - shouldNotifyForEvent(event, normalizedCurrentPubkey, { - participatedRootIds: options.participatedRootIds ?? EMPTY_SET, - followedRootIds: options.followedRootIds ?? EMPTY_SET, - authoredRootIds: options.authoredRootIds ?? EMPTY_SET, - mutedRootIds: options.mutedRootIds ?? EMPTY_SET, - mutedChannelIds: options.mutedChannelIds ?? EMPTY_SET, - channelId, - }) - ) { - options.onChannelMessage?.(channelId, event); - const ref = getThreadReference(event.tags); - const isThreadReply = - ref.parentId !== null && !isBroadcastReply(event.tags); - if (isThreadReply) { - if (channelId !== activeChannelId) { - options.onThreadReplyNotification?.(channelId, event); + event.pubkey.toLowerCase() !== normalizedCurrentPubkey); + const isThreadedReply = isThreadReply(event.tags); + + if (isExternalTriggerEvent) { + const shouldNotify = shouldNotifyForEvent( + event, + normalizedCurrentPubkey, + { + participatedRootIds: options.participatedRootIds ?? EMPTY_SET, + followedRootIds: options.followedRootIds ?? EMPTY_SET, + authoredRootIds: options.authoredRootIds ?? EMPTY_SET, + mutedRootIds: options.mutedRootIds ?? EMPTY_SET, + mutedChannelIds: options.mutedChannelIds ?? EMPTY_SET, + channelId, + }, + ); + + if (!shouldNotify) { + if (isThreadedReply) { + options.onThreadReplyCandidate?.(channelId, event); } + } else if (!isThreadedReply) { + options.onChannelMessage?.(channelId, event); + } else { + options.onThreadReplyNotification?.(channelId, event); + } + + if (shouldNotify && isThreadedReply) { if ( !dmChannelMap.has(channelId) && (channelId !== activeChannelId || options.notifyForActiveChannel) diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index b440a6fa4..cc74ec56f 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -8,6 +8,7 @@ import { useReadState } from "@/features/channels/readState/useReadState"; import { getThreadReference, isBroadcastReply, + isThreadReply, } from "@/features/messages/lib/threading"; import { hasMentionForEvent, @@ -30,6 +31,10 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // filter to find one external trigger message. 1000 matches the live sub's // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; +const THREAD_INTEREST_CHECK_LIMIT = 1; +const THREAD_INTEREST_BACKFILL_LIMIT = 300; +const THREAD_ACTIVITY_BACKFILL_LIMIT = 150; +const THREAD_ACTIVITY_ROOT_BATCH_SIZE = 50; // All four thread root-id sets (participation, authored, mentioned, muted) // share the same localStorage shape: a per-pubkey JSON array of ids, capped to @@ -127,6 +132,53 @@ function writeActivityToStorage( } } +function addThreadActivityItems( + existing: ThreadActivityItem[], + items: ThreadActivityItem[], +) { + if (items.length === 0) { + return { didAdd: false, items: existing }; + } + + const existingIds = new Set(existing.map((item) => item.id)); + const newItems = items.filter((item) => !existingIds.has(item.id)); + if (newItems.length === 0) { + return { didAdd: false, items: existing }; + } + + const merged = [...existing, ...newItems].sort( + (left, right) => left.createdAt - right.createdAt, + ); + const capped = + merged.length > MAX_ACTIVITY_ITEMS + ? merged.slice(merged.length - MAX_ACTIVITY_ITEMS) + : merged; + + return { didAdd: true, items: capped }; +} + +function recordSelfThreadInterest( + event: RelayEvent, + participatedRootIds: Set, + authoredRootIds: Set, +): string { + const ref = getThreadReference(event.tags); + if (ref.rootId !== null) { + participatedRootIds.add(ref.rootId); + return ref.rootId; + } + + authoredRootIds.add(event.id); + return event.id; +} + +function threadInterestResolutionKey( + channelId: string, + rootId: string, +): string { + return `${channelId}:${rootId}`; +} + function parseTimestamp(value: string | null | undefined) { if (!value) { return null; @@ -142,14 +194,14 @@ function toUnixSeconds(isoOrMs: string | null | undefined): number | null { } // Resolve where the read marker should land when a channel is marked read. -// Folds the caller's timeline position together with the newest event this -// client has observed live (`observedLatest`), so an explicit "mark read" still -// covers messages that arrived faster than channel metadata — this fold is -// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open, -// all of which pass a null/stale caller value. `clearObserved` reports whether -// the resulting marker covers the observed timestamp, signalling the caller to -// drop its observed refs so the unread memo sees `latest === undefined` until a -// genuinely newer event arrives. +// Folds the caller's timeline position together with the newest main-channel +// event this client has observed live (`observedLatest`), so an explicit +// "mark read" still covers messages that arrived faster than channel metadata. +// This fold is load-bearing for the Esc shortcut, sidebar mark-read, and +// empty-channel open, all of which pass a null/stale caller value. +// `clearObserved` reports whether the resulting marker covers the observed +// timestamp, signalling the caller to drop its observed refs so the unread memo +// sees `latest === undefined` until a genuinely newer event arrives. export function resolveChannelReadMarker( callerReadAt: string | null | undefined, observedLatest: number | undefined, @@ -173,51 +225,44 @@ function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { return true; } -// Build channelId -> set of thread rootIds observed in that channel, derived -// from the thread-activity log (the same items that feed latestByChannelRef). -// Used by the sidebar unread scan to fold per-thread read markers into a -// channel's effective frontier so opening a thread clears the channel dot. -export function buildChannelThreadRoots( - items: readonly ThreadActivityItem[], - getRootId: (tags: string[][]) => string | null, -): Map> { - const byChannel = new Map>(); - for (const item of items) { - const rootId = getRootId(item.tags); - if (rootId === null) continue; - let roots = byChannel.get(item.channelId); - if (!roots) { - roots = new Set(); - byChannel.set(item.channelId, roots); - } - roots.add(rootId); +function addUnreadChannelEvent( + target: Map>, + channelId: string, + eventId: string, + createdAt: number, +): boolean { + let channelEvents = target.get(channelId); + if (!channelEvents) { + channelEvents = new Map(); + target.set(channelId, channelEvents); + } + + const current = channelEvents.get(eventId); + if (current === createdAt) { + return false; } - return byChannel; + + channelEvents.set(eventId, createdAt); + return true; } -// The channel's effective read frontier for sidebar-unread purposes: its own -// channel marker folded with the highest OWN thread marker among its observed -// thread roots. Using the thread OWN marker (not the hierarchical effective -// value) is deliberate — the hierarchical resolver maps every thread to the -// ACTIVE channel, so it would borrow the wrong marker for a background channel. -// An unread reply in a thread keeps the dot until that thread is opened -// (advancing the thread marker past the reply); a never-read thread (no own -// marker) contributes nothing and the channel marker governs. -export function channelUnreadFrontier( - channelMarker: number | null, - threadRoots: ReadonlySet | undefined, - getThreadOwnMarker: (rootId: string) => number | null, -): number | null { - let frontier = channelMarker; - if (threadRoots) { - for (const rootId of threadRoots) { - const own = getThreadOwnMarker(rootId); - if (own !== null && (frontier === null || own > frontier)) { - frontier = own; - } +function pruneUnreadChannelEvents( + target: Map>, + channelId: string, + readAt: number, +) { + const channelEvents = target.get(channelId); + if (!channelEvents) return; + + for (const [eventId, createdAt] of channelEvents) { + if (createdAt <= readAt) { + channelEvents.delete(eventId); } } - return frontier; + + if (channelEvents.size === 0) { + target.delete(channelId); + } } export function useUnreadChannels( @@ -241,20 +286,24 @@ export function useUnreadChannels( drainSyncedAdvances, setContextParentResolver, readStateVersion, - getOwnTimestamp, } = useReadState(pubkey, relayClient); - // Observed "latest external trigger event" per channel (unix seconds). This + // Observed newest external main-channel event per channel (unix seconds). This // is *derived relay evidence*, not source-of-truth: it's populated from a // one-shot catch-up REQ per channel (keyed on the NIP-RS read marker) plus - // ongoing live events. The only thing we ever do with it is compare against - // the NIP-RS read marker — see the unread memo below. Reset on identity - // change. Stale entries for channels the user has left are silently - // ignored by the memo (it iterates the current channels list, not the map). + // ongoing live events. Thread replies are recorded separately for Home inbox + // activity and never enter this map. The only thing we ever do with it is + // compare against the NIP-RS read marker — see the unread memo below. Reset + // on identity change. Stale entries for channels the user has left are + // silently ignored by the memo (it iterates the current channels list, not + // the map). const latestByChannelRef = React.useRef(new Map()); const latestHighPriorityByChannelRef = React.useRef( new Map(), ); + const unreadEventsByChannelRef = React.useRef( + new Map>(), + ); const channelsRef = React.useRef(channels); channelsRef.current = channels; @@ -305,6 +354,14 @@ export function useUnreadChannels( // activity feed as synthetic FeedItems. const threadActivityRef = React.useRef([]); + // Root IDs whose authored/participated interest had to be checked against + // the relay because this local client had not observed the user's earlier + // root/reply yet. + const threadInterestResolutionsRef = React.useRef( + new Map>(), + ); + const threadActivityBackfillKeyRef = React.useRef(null); + // 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. @@ -331,6 +388,7 @@ export function useUnreadChannels( React.useEffect(() => { latestByChannelRef.current = new Map(); latestHighPriorityByChannelRef.current = new Map(); + unreadEventsByChannelRef.current = new Map(); forcedUnreadRef.current = new Set(); caughtUpChannelsRef.current = new Set(); participatedRootIdsRef.current = pubkey @@ -344,20 +402,17 @@ export function useUnreadChannels( : new Set(); mutedRootIdsRef.current = pubkey ? mutedStore.read(pubkey) : new Set(); threadActivityRef.current = pubkey ? readActivityFromStorage(pubkey) : []; + threadInterestResolutionsRef.current = new Map(); + threadActivityBackfillKeyRef.current = null; bumpLatestVersion(); bumpMembershipVersion(); }, [pubkey, relayClient]); // `topLevelOnly` is the passive channel-open path (NIP-RS Option 1): the // caller's `readAt` is already the newest TOP-LEVEL message, so the marker - // must land exactly there without folding in `observedLatest` (which counts - // thread replies) and without clearing observed refs. Leaving the refs intact - // keeps the sidebar dot lit for a channel whose only unread is an unopened - // thread reply — viewing the channel no longer absorbs the reply, so the dot - // persists until an explicit mark-read (Esc, sidebar, mark-all) or a newer - // top-level message advances the channel marker past it. Those explicit - // "mark read" actions omit the flag and keep the fold, since they mean - // "clear everything in this channel." + // must land exactly there without folding in a newer observed top-level + // message. Thread replies are tracked separately as Home inbox activity and + // never participate in the sidebar channel dot. const markChannelRead = React.useCallback( ( channelId: string, @@ -376,6 +431,11 @@ export function useUnreadChannels( ); if (markAt === null) return; markContextRead(channelId, markAt); + pruneUnreadChannelEvents( + unreadEventsByChannelRef.current, + channelId, + markAt, + ); // Clear observed-latest refs when the read marker covers them so the // unread memo sees `latest === undefined` until a genuinely new event // arrives. Without this, `latest > readAt` resolves to `T > T` (false) @@ -422,22 +482,195 @@ export function useUnreadChannels( [normalizedPubkey], ); - // Feed the in-session "latest external trigger" map from live channel - // events. Composes with any caller-supplied onChannelMessage handler. + const resolveThreadInterestFromRelay = React.useCallback( + async (rootId: string, channelId: string): Promise => { + if (!relayClient || normalizedPubkey === null) { + return false; + } + + if ( + participatedRootIdsRef.current.has(rootId) || + authoredRootIdsRef.current.has(rootId) + ) { + return true; + } + + if ( + mutedRootIdsRef.current.has(rootId) || + mutedChannelIdsRef.current.has(channelId) + ) { + return false; + } + + const resolutionKey = threadInterestResolutionKey(channelId, rootId); + const cached = threadInterestResolutionsRef.current.get(resolutionKey); + if (cached) { + return cached; + } + + const resolution = (async () => { + try { + const [authoredRootEvents, participatedEvents] = await Promise.all([ + relayClient.fetchEvents({ + ids: [rootId], + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + authors: [normalizedPubkey], + "#h": [channelId], + limit: THREAD_INTEREST_CHECK_LIMIT, + }), + relayClient.fetchEvents({ + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + authors: [normalizedPubkey], + "#e": [rootId], + "#h": [channelId], + limit: THREAD_INTEREST_CHECK_LIMIT, + }), + ]); + + let didResolveInterest = false; + if (authoredRootEvents.length > 0) { + authoredRootIdsRef.current.add(rootId); + didResolveInterest = true; + } + if (participatedEvents.length > 0) { + participatedRootIdsRef.current.add(rootId); + didResolveInterest = true; + } + + if (didResolveInterest) { + authoredStore.write(normalizedPubkey, authoredRootIdsRef.current); + participationStore.write( + normalizedPubkey, + participatedRootIdsRef.current, + ); + bumpLatestVersion(); + bumpMembershipVersion(); + } + + return didResolveInterest; + } catch { + threadInterestResolutionsRef.current.delete(resolutionKey); + return false; + } + })(); + + threadInterestResolutionsRef.current.set(resolutionKey, resolution); + return resolution; + }, + [normalizedPubkey, relayClient], + ); + + const resolveThreadInterestsFromRelay = React.useCallback( + async (rootIds: string[], channelId: string): Promise => { + if (!relayClient || normalizedPubkey === null || rootIds.length === 0) { + return; + } + + const unresolvedRootIds = [...new Set(rootIds)].filter( + (rootId) => + !participatedRootIdsRef.current.has(rootId) && + !authoredRootIdsRef.current.has(rootId) && + !mutedRootIdsRef.current.has(rootId) && + !mutedChannelIdsRef.current.has(channelId), + ); + if (unresolvedRootIds.length === 0) { + return; + } + + try { + const [authoredRootEvents, participatedEvents] = await Promise.all([ + relayClient.fetchEvents({ + ids: unresolvedRootIds, + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + authors: [normalizedPubkey], + "#h": [channelId], + limit: Math.min(unresolvedRootIds.length, CATCH_UP_LIMIT), + }), + relayClient.fetchEvents({ + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + authors: [normalizedPubkey], + "#e": unresolvedRootIds, + "#h": [channelId], + limit: CATCH_UP_LIMIT, + }), + ]); + + const unresolved = new Set(unresolvedRootIds); + const resolved = new Set(); + for (const event of authoredRootEvents) { + if (unresolved.has(event.id)) { + authoredRootIdsRef.current.add(event.id); + resolved.add(event.id); + } + } + for (const event of participatedEvents) { + const rootId = getThreadReference(event.tags).rootId; + if (rootId !== null && unresolved.has(rootId)) { + participatedRootIdsRef.current.add(rootId); + resolved.add(rootId); + } + } + + for (const rootId of unresolvedRootIds) { + threadInterestResolutionsRef.current.set( + threadInterestResolutionKey(channelId, rootId), + Promise.resolve(resolved.has(rootId)), + ); + } + + if (resolved.size > 0) { + authoredStore.write(normalizedPubkey, authoredRootIdsRef.current); + participationStore.write( + normalizedPubkey, + participatedRootIdsRef.current, + ); + bumpLatestVersion(); + bumpMembershipVersion(); + } + } catch { + for (const rootId of unresolvedRootIds) { + threadInterestResolutionsRef.current.delete( + threadInterestResolutionKey(channelId, rootId), + ); + } + } + }, + [normalizedPubkey, relayClient], + ); + + // Feed the in-session newest-main-channel map from live channel events. + // Composes with any caller-supplied onChannelMessage handler. // useLiveChannelUpdates already filters this callback to trigger kinds // and external authors, so the map is always a strict subset of "newest - // external trigger message this client has observed." + // external main-channel message this client has observed." const callerOnChannelMessage = liveUpdateOptions.onChannelMessage; + const callerOnThreadReplyDesktopNotification = + liveUpdateOptions.onThreadReplyDesktopNotification; + const notifyForActiveChannel = liveUpdateOptions.notifyForActiveChannel; const handleChannelMessage = React.useCallback( (channelId: string, event: RelayEvent) => { + if (isThreadReply(event.tags)) { + return; + } + const current = latestByChannelRef.current.get(channelId) ?? 0; if (event.created_at > current) { latestByChannelRef.current.set(channelId, event.created_at); bumpLatestVersion(); } + if ( + addUnreadChannelEvent( + unreadEventsByChannelRef.current, + channelId, + event.id, + event.created_at, + ) + ) { + bumpLatestVersion(); + } - // A mention on a reply makes its thread badge-eligible even when the - // user never participated/authored/followed (the gate's missing term). + // Broadcast-style replies are treated as main-channel activity, but can + // still carry a thread root that should make the thread badge-eligible. if (recordMentionedRoot(event)) { bumpMembershipVersion(); } @@ -467,25 +700,30 @@ export function useUnreadChannels( const handleSelfChannelMessage = React.useCallback( (event: RelayEvent) => { - const ref = getThreadReference(event.tags); - // Participation roots key on the thread root; authored roots (no thread - // ref) key on the event id itself. - const isParticipation = ref.rootId !== null; - const targetSet = isParticipation - ? participatedRootIdsRef.current - : authoredRootIdsRef.current; - const sizeBefore = targetSet.size; - targetSet.add(ref.rootId ?? event.id); + const participatedSizeBefore = participatedRootIdsRef.current.size; + const authoredSizeBefore = authoredRootIdsRef.current.size; + const rootId = recordSelfThreadInterest( + event, + participatedRootIdsRef.current, + authoredRootIdsRef.current, + ); + const channelId = event.tags.find((tag) => tag[0] === "h")?.[1]; + if (channelId) { + threadInterestResolutionsRef.current.delete( + threadInterestResolutionKey(channelId, rootId), + ); + } if (normalizedPubkey !== null) { - const write = isParticipation - ? participationStore.write - : authoredStore.write; - write(normalizedPubkey, targetSet); - } - // Only re-derive the gate snapshot when the set actually grew; a self-post - // to an already-tracked root is a no-op for the notify gate, so skipping - // the bump avoids a wasted snapshot re-allocation + gate recompute. - if (targetSet.size !== sizeBefore) { + participationStore.write( + normalizedPubkey, + participatedRootIdsRef.current, + ); + authoredStore.write(normalizedPubkey, authoredRootIdsRef.current); + } + if ( + participatedRootIdsRef.current.size !== participatedSizeBefore || + authoredRootIdsRef.current.size !== authoredSizeBefore + ) { bumpMembershipVersion(); } bumpLatestVersion(); @@ -495,6 +733,10 @@ export function useUnreadChannels( const handleThreadReplyNotification = React.useCallback( (channelId: string, event: RelayEvent) => { + if (recordMentionedRoot(event)) { + bumpMembershipVersion(); + } + const channelName = channels.find((ch) => ch.id === channelId)?.name ?? ""; const item: ThreadActivityItem = { @@ -520,7 +762,59 @@ export function useUnreadChannels( } bumpLatestVersion(); }, - [channels, normalizedPubkey], + [channels, normalizedPubkey, recordMentionedRoot], + ); + + const handleThreadReplyCandidate = React.useCallback( + (channelId: string, event: RelayEvent) => { + const ref = getThreadReference(event.tags); + const rootId = ref.rootId; + if (rootId === null) { + return; + } + + void resolveThreadInterestFromRelay(rootId, channelId).then( + (hasThreadInterest) => { + if (!hasThreadInterest) { + return; + } + + if ( + !shouldNotifyForEvent(event, normalizedPubkey ?? "", { + participatedRootIds: participatedRootIdsRef.current, + followedRootIds: liveUpdateOptions.followedRootIds ?? EMPTY_SET, + authoredRootIds: authoredRootIdsRef.current, + mutedRootIds: mutedRootIdsRef.current, + mutedChannelIds: mutedChannelIdsRef.current, + channelId, + }) + ) { + return; + } + + handleThreadReplyNotification(channelId, event); + + const channel = channelsRef.current.find( + (entry) => entry.id === channelId, + ); + if ( + channel?.channelType !== "dm" && + (channelId !== activeChannelId || notifyForActiveChannel) + ) { + callerOnThreadReplyDesktopNotification?.(channelId, event); + } + }, + ); + }, + [ + activeChannelId, + callerOnThreadReplyDesktopNotification, + handleThreadReplyNotification, + liveUpdateOptions.followedRootIds, + normalizedPubkey, + notifyForActiveChannel, + resolveThreadInterestFromRelay, + ], ); const muteThread = React.useCallback( @@ -549,6 +843,7 @@ export function useUnreadChannels( ...liveUpdateOptions, onChannelMessage: handleChannelMessage, onThreadReplyNotification: handleThreadReplyNotification, + onThreadReplyCandidate: handleThreadReplyCandidate, onSelfChannelMessage: handleSelfChannelMessage, participatedRootIds: participatedRootIdsRef.current, followedRootIds: liveUpdateOptions.followedRootIds, @@ -565,6 +860,273 @@ export function useUnreadChannels( () => [...new Set(channels.map((channel) => channel.id))].sort().join(","), [channels], ); + const followedRootIdsKey = React.useMemo( + () => + [...(liveUpdateOptions.followedRootIds ?? EMPTY_SET)].sort().join(","), + [liveUpdateOptions.followedRootIds], + ); + + React.useEffect(() => { + if ( + !relayClient || + normalizedPubkey === null || + channelIdsKey.length === 0 + ) { + return; + } + + const backfillKey = `${normalizedPubkey}:${channelIdsKey}:${followedRootIdsKey}`; + if (threadActivityBackfillKeyRef.current === backfillKey) { + return; + } + threadActivityBackfillKeyRef.current = backfillKey; + + let isCancelled = false; + const targetIds = channelIdsKey.split(","); + const channelById = new Map( + channels.map((channel) => [channel.id, channel]), + ); + + void (async () => { + const participatedSizeBefore = participatedRootIdsRef.current.size; + const authoredSizeBefore = authoredRootIdsRef.current.size; + + const [selfEvents, recentChannelEvents] = await Promise.all([ + relayClient + .fetchEvents({ + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + authors: [normalizedPubkey], + "#h": targetIds, + limit: THREAD_INTEREST_BACKFILL_LIMIT, + }) + .catch(() => []), + relayClient + .fetchEvents({ + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + "#h": targetIds, + limit: THREAD_ACTIVITY_BACKFILL_LIMIT, + }) + .catch(() => []), + ]); + + if (isCancelled) { + return; + } + + const interestedRootIds = new Set([ + ...participatedRootIdsRef.current, + ...authoredRootIdsRef.current, + ...(liveUpdateOptions.followedRootIds ?? EMPTY_SET), + ]); + + for (const event of selfEvents) { + interestedRootIds.add( + recordSelfThreadInterest( + event, + participatedRootIdsRef.current, + authoredRootIdsRef.current, + ), + ); + } + + if (normalizedPubkey !== null) { + participationStore.write( + normalizedPubkey, + participatedRootIdsRef.current, + ); + authoredStore.write(normalizedPubkey, authoredRootIdsRef.current); + } + + const threadReplies: ThreadActivityItem[] = []; + const candidateRepliesByChannel = new Map(); + const candidateRootIdsByChannel = new Map>(); + + for (const event of recentChannelEvents) { + if (event.pubkey.toLowerCase() === normalizedPubkey) { + continue; + } + + const ref = getThreadReference(event.tags); + if ( + ref.parentId === null || + ref.rootId === null || + isBroadcastReply(event.tags) + ) { + continue; + } + + const channelId = event.tags.find((tag) => tag[0] === "h")?.[1] ?? null; + if ( + channelId === null || + mutedChannelIdsRef.current.has(channelId) || + mutedRootIdsRef.current.has(ref.rootId) + ) { + continue; + } + + const replies = candidateRepliesByChannel.get(channelId) ?? []; + replies.push(event); + candidateRepliesByChannel.set(channelId, replies); + + const roots = candidateRootIdsByChannel.get(channelId) ?? new Set(); + roots.add(ref.rootId); + candidateRootIdsByChannel.set(channelId, roots); + } + + await Promise.all( + [...candidateRootIdsByChannel.entries()].map(([channelId, roots]) => + resolveThreadInterestsFromRelay([...roots], channelId), + ), + ); + + if (isCancelled) { + return; + } + + for (const [channelId, events] of candidateRepliesByChannel) { + const channelName = channelById.get(channelId)?.name ?? ""; + for (const event of events) { + if ( + !shouldNotifyForEvent(event, normalizedPubkey, { + participatedRootIds: participatedRootIdsRef.current, + followedRootIds: liveUpdateOptions.followedRootIds ?? EMPTY_SET, + authoredRootIds: authoredRootIdsRef.current, + mutedRootIds: mutedRootIdsRef.current, + mutedChannelIds: mutedChannelIdsRef.current, + channelId, + }) + ) { + continue; + } + + threadReplies.push({ + id: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + createdAt: event.created_at, + channelId, + channelName, + tags: [...event.tags], + }); + } + } + + const rootIds = [...interestedRootIds].filter( + (rootId) => !mutedRootIdsRef.current.has(rootId), + ); + if (rootIds.length === 0) { + const added = addThreadActivityItems( + threadActivityRef.current, + threadReplies, + ); + if (added.didAdd) { + threadActivityRef.current = added.items; + writeActivityToStorage(normalizedPubkey, added.items); + bumpLatestVersion(); + } + + if ( + participatedRootIdsRef.current.size !== participatedSizeBefore || + authoredRootIdsRef.current.size !== authoredSizeBefore + ) { + bumpMembershipVersion(); + } + + return; + } + for ( + let start = 0; + start < rootIds.length && + threadReplies.length < THREAD_ACTIVITY_BACKFILL_LIMIT; + start += THREAD_ACTIVITY_ROOT_BATCH_SIZE + ) { + const rootBatch = rootIds.slice( + start, + start + THREAD_ACTIVITY_ROOT_BATCH_SIZE, + ); + const events = await relayClient + .fetchEvents({ + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + "#e": rootBatch, + "#h": targetIds, + limit: THREAD_ACTIVITY_BACKFILL_LIMIT, + }) + .catch(() => []); + + if (isCancelled) { + return; + } + + for (const event of events) { + if (event.pubkey.toLowerCase() === normalizedPubkey) { + continue; + } + + const ref = getThreadReference(event.tags); + if ( + ref.parentId === null || + ref.rootId === null || + !interestedRootIds.has(ref.rootId) || + isBroadcastReply(event.tags) + ) { + continue; + } + + const channelId = + event.tags.find((tag) => tag[0] === "h")?.[1] ?? null; + if ( + channelId === null || + mutedChannelIdsRef.current.has(channelId) || + mutedRootIdsRef.current.has(ref.rootId) + ) { + continue; + } + + const channelName = channelById.get(channelId)?.name ?? ""; + threadReplies.push({ + id: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + createdAt: event.created_at, + channelId, + channelName, + tags: [...event.tags], + }); + } + } + + const added = addThreadActivityItems( + threadActivityRef.current, + threadReplies, + ); + if (added.didAdd) { + threadActivityRef.current = added.items; + writeActivityToStorage(normalizedPubkey, added.items); + bumpLatestVersion(); + } + + if ( + participatedRootIdsRef.current.size !== participatedSizeBefore || + authoredRootIdsRef.current.size !== authoredSizeBefore + ) { + bumpMembershipVersion(); + } + })(); + + return () => { + isCancelled = true; + }; + }, [ + channelIdsKey, + channels, + followedRootIdsKey, + liveUpdateOptions.followedRootIds, + normalizedPubkey, + relayClient, + resolveThreadInterestsFromRelay, + ]); // Catch-up: for each channel we haven't already caught up this session, // ask the relay "are there any external trigger messages newer than the @@ -605,6 +1167,7 @@ export function useUnreadChannels( | { channelId: string; ok: true; + mainChannelEvents: Array<{ createdAt: number; id: string }>; maxExternal: number; maxHighPriority: number; threadReplies: ThreadActivityItem[]; @@ -635,11 +1198,18 @@ export function useUnreadChannels( normalizedPubkey !== null && event.pubkey.toLowerCase() === normalizedPubkey; if (isSelf) { - const ref = getThreadReference(event.tags); - if (ref.rootId !== null) { - participatedRootIdsRef.current.add(ref.rootId); - } else { - authoredRootIdsRef.current.add(event.id); + const rootId = recordSelfThreadInterest( + event, + participatedRootIdsRef.current, + authoredRootIdsRef.current, + ); + const eventChannelId = event.tags.find( + (tag) => tag[0] === "h", + )?.[1]; + if (eventChannelId) { + threadInterestResolutionsRef.current.delete( + threadInterestResolutionKey(eventChannelId, rootId), + ); } } else { recordMentionedRoot(event); @@ -654,10 +1224,55 @@ export function useUnreadChannels( authoredStore.write(normalizedPubkey, authoredRootIdsRef.current); } - // Pass 2: compute maxExternal and collect thread reply activity, - // applying the notification filter to both. + if (normalizedPubkey !== null) { + const rootIdsToBackfill = new Set(); + for (const event of events) { + if ( + event.pubkey.toLowerCase() === normalizedPubkey || + (readAt !== null && event.created_at <= readAt) + ) { + continue; + } + + const evtRef = getThreadReference(event.tags); + if ( + evtRef.parentId === null || + evtRef.rootId === null || + isBroadcastReply(event.tags) + ) { + continue; + } + + const notifyChannelId = + event.tags.find((t) => t[0] === "h")?.[1] ?? channelId; + if ( + shouldNotifyForEvent(event, normalizedPubkey, { + participatedRootIds: participatedRootIdsRef.current, + followedRootIds: options.followedRootIds ?? EMPTY_SET, + authoredRootIds: authoredRootIdsRef.current, + mutedRootIds: mutedRootIdsRef.current, + mutedChannelIds: mutedChannelIdsRef.current, + channelId: notifyChannelId, + }) + ) { + continue; + } + + rootIdsToBackfill.add(evtRef.rootId); + } + + await resolveThreadInterestsFromRelay( + [...rootIdsToBackfill], + channelId, + ); + } + + // Pass 2: compute the newest main-channel unread event and collect + // thread reply activity, applying the notification filter to both. let maxExternal = 0; let maxHighPriority = 0; + const mainChannelEvents: Array<{ createdAt: number; id: string }> = + []; const threadReplies: ThreadActivityItem[] = []; const ch = channels.find((c) => c.id === channelId); const chType = ch?.channelType; @@ -672,6 +1287,8 @@ export function useUnreadChannels( if (readAt !== null && event.created_at <= readAt) continue; const eventChannelId = event.tags.find((t) => t[0] === "h")?.[1] ?? null; + const notifyChannelId = eventChannelId ?? channelId; + const evtRef = getThreadReference(event.tags); if ( !shouldNotifyForEvent(event, normalizedPubkey ?? "", { participatedRootIds: participatedRootIdsRef.current, @@ -679,25 +1296,32 @@ export function useUnreadChannels( authoredRootIds: authoredRootIdsRef.current, mutedRootIds: mutedRootIdsRef.current, mutedChannelIds: mutedChannelIdsRef.current, - channelId: eventChannelId, + channelId: notifyChannelId, }) ) { continue; } - if (event.created_at > maxExternal) { - maxExternal = event.created_at; - } - if ( - chType === "dm" || - (normalizedPubkey !== null && - isHighPriorityEventForUser(event, normalizedPubkey)) - ) { - if (event.created_at > maxHighPriority) { - maxHighPriority = event.created_at; + const isThreadedReply = + evtRef.parentId !== null && !isBroadcastReply(event.tags); + if (!isThreadedReply) { + mainChannelEvents.push({ + id: event.id, + createdAt: event.created_at, + }); + if (event.created_at > maxExternal) { + maxExternal = event.created_at; + } + if ( + chType === "dm" || + (normalizedPubkey !== null && + isHighPriorityEventForUser(event, normalizedPubkey)) + ) { + if (event.created_at > maxHighPriority) { + maxHighPriority = event.created_at; + } } } - const evtRef = getThreadReference(event.tags); - if (evtRef.parentId !== null && !isBroadcastReply(event.tags)) { + if (isThreadedReply) { threadReplies.push({ id: event.id, kind: event.kind, @@ -714,6 +1338,7 @@ export function useUnreadChannels( return { channelId, ok: true, + mainChannelEvents, maxExternal, maxHighPriority, threadReplies, @@ -734,9 +1359,26 @@ export function useUnreadChannels( caughtUpChannelsRef.current.delete(result.channelId); continue; } - const { channelId, maxExternal, maxHighPriority, threadReplies } = - result; + const { + channelId, + mainChannelEvents, + maxExternal, + maxHighPriority, + threadReplies, + } = result; allThreadReplies.push(...threadReplies); + for (const event of mainChannelEvents) { + if ( + addUnreadChannelEvent( + unreadEventsByChannelRef.current, + channelId, + event.id, + event.createdAt, + ) + ) { + didAdvance = true; + } + } if (maxExternal > 0) { const readAtNow = getEffectiveTimestamp(channelId) ?? 0; if (maxExternal > readAtNow) { @@ -805,10 +1447,11 @@ export function useUnreadChannels( isReadStateReady, normalizedPubkey, relayClient, + resolveThreadInterestsFromRelay, ]); // Unread = channels (excluding active) that have either been manually - // marked unread this session, or whose observed latest external trigger + // marked unread this session, or whose observed latest external main-channel // timestamp is strictly newer than their NIP-RS read marker. // High-priority unread = DMs or channels with a mention/broadcast newer // than the read marker. Forced-unread channels are dot tier only (not @@ -821,42 +1464,40 @@ export function useUnreadChannels( return { unreadChannelIds: new Set(), highPriorityUnreadChannelIds: new Set(), + unreadChannelNotificationCount: 0, }; } const unread = new Set(); const highPriority = new Set(); - - // Map each channel to the thread roots observed in it, so a channel's - // frontier can fold in per-thread read markers (Option A): opening a - // thread advances thread: and must clear the channel dot even - // though markChannelRead only advances the channel marker to the newest - // TOP-LEVEL message. - const threadRootsByChannel = buildChannelThreadRoots( - threadActivityRef.current, - (tags) => getThreadReference(tags).rootId, - ); + let notificationCount = 0; for (const channel of channels) { if (channel.id === activeChannelId) continue; if (forcedUnreadRef.current.has(channel.id)) { - // Forced-unread is dot tier only — not high-priority. + // Forced-unread is a manual notification bucket; there is no event + // count to recover, so count the channel once. unread.add(channel.id); + notificationCount += 1; continue; } const latest = latestByChannelRef.current.get(channel.id); if (latest === undefined) continue; - const readAt = channelUnreadFrontier( - getEffectiveTimestamp(channel.id), - threadRootsByChannel.get(channel.id), - (rootId) => getOwnTimestamp(`thread:${rootId}`), - ); + const readAt = getEffectiveTimestamp(channel.id); if (readAt !== null && latest <= readAt) continue; unread.add(channel.id); + const channelEvents = unreadEventsByChannelRef.current.get(channel.id); + const unreadEventCount = + channelEvents === undefined + ? 0 + : [...channelEvents.values()].filter( + (createdAt) => readAt === null || createdAt > readAt, + ).length; + notificationCount += Math.max(1, unreadEventCount); // DM channels: any unread DM is high-priority. if (channel.channelType === "dm") { @@ -878,12 +1519,12 @@ export function useUnreadChannels( return { unreadChannelIds: unread, highPriorityUnreadChannelIds: highPriority, + unreadChannelNotificationCount: notificationCount, }; }, [ activeChannelId, channels, getEffectiveTimestamp, - getOwnTimestamp, isReadStateReady, latestVersion, readStateVersion, @@ -909,6 +1550,8 @@ export function useUnreadChannels( ? prevHighPriorityRef.current : rawUnread.highPriorityUnreadChannelIds; prevHighPriorityRef.current = highPriorityUnreadChannelIds; + const unreadChannelNotificationCount = + rawUnread.unreadChannelNotificationCount; const unreadChannelIdsRef = React.useRef(unreadChannelIds); unreadChannelIdsRef.current = unreadChannelIds; @@ -925,6 +1568,7 @@ export function useUnreadChannels( } latestByChannelRef.current.delete(channelId); latestHighPriorityByChannelRef.current.delete(channelId); + unreadEventsByChannelRef.current.delete(channelId); } bumpLatestVersion(); }, [getEffectiveTimestamp, markContextRead]); @@ -952,6 +1596,7 @@ export function useUnreadChannels( return { unreadChannelIds, highPriorityUnreadChannelIds, + unreadChannelNotificationCount, markAllChannelsRead, markChannelRead, markChannelUnread, diff --git a/desktop/src/features/home/lib/inbox.test.mjs b/desktop/src/features/home/lib/inbox.test.mjs new file mode 100644 index 000000000..02c3a9c91 --- /dev/null +++ b/desktop/src/features/home/lib/inbox.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInboxItems, getInboxTypeLabel } from "./inbox.ts"; + +const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +const channels = [ + { + id: CHANNEL_ID, + name: "buzz-bugs", + channelType: "stream", + }, +]; + +function feedWith(overrides) { + return { + feed: { + mentions: overrides.mentions ?? [], + needsAction: overrides.needsAction ?? [], + activity: overrides.activity ?? [], + agentActivity: overrides.agentActivity ?? [], + }, + meta: { + since: 0, + total: 0, + generatedAt: 0, + }, + }; +} + +function item(overrides) { + return { + id: overrides.id ?? "event-1", + kind: overrides.kind ?? 9, + pubkey: overrides.pubkey ?? "author", + content: overrides.content ?? "hello", + createdAt: overrides.createdAt ?? 1, + channelId: overrides.channelId ?? CHANNEL_ID, + channelName: overrides.channelName ?? "", + tags: overrides.tags ?? [["h", CHANNEL_ID]], + category: overrides.category ?? "mention", + }; +} + +test("mention rows use the channel list when feed channelName is blank", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + mentions: [item({ category: "mention" })], + }), + }); + + assert.deepEqual(getInboxTypeLabel(inboxItem), { + text: "Mentioned in", + channelLabel: "buzz-bugs", + }); +}); + +test("thread activity rows use the channel list when feed channelName is blank", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + category: "activity", + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }), + ], + }), + }); + + assert.deepEqual(getInboxTypeLabel(inboxItem), { + text: "Thread in", + channelLabel: "buzz-bugs", + }); +}); + +test("thread groups are represented by the latest reply rather than the root", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "root-event", + category: "activity", + content: "Original thread starter", + createdAt: 1, + }), + item({ + id: "reply-event", + category: "activity", + content: "New reply in the thread", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }), + ], + }), + }); + + assert.equal(inboxItem.id, "reply-event"); + assert.equal(inboxItem.preview, "New reply in the thread"); + assert.deepEqual(getInboxTypeLabel(inboxItem), { + text: "Thread in", + channelLabel: "buzz-bugs", + }); +}); + +test("thread groups use the latest row label even when the root was a mention", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + mentions: [ + item({ + id: "root-event", + category: "mention", + content: "Original mention", + createdAt: 1, + }), + ], + activity: [ + item({ + id: "reply-event", + category: "activity", + content: "New reply in the thread", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }), + ], + }), + }); + + assert.equal(inboxItem.id, "reply-event"); + assert.deepEqual(getInboxTypeLabel(inboxItem), { + text: "Thread in", + channelLabel: "buzz-bugs", + }); +}); diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 99d4fe31b..5f4a13028 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -2,9 +2,13 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { getThreadReference } from "@/features/messages/lib/threading"; +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; import type { TimelineReaction } from "@/features/messages/types"; import type { + Channel, FeedItem, FeedItemCategory, HomeFeedResponse, @@ -15,6 +19,7 @@ import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; export type InboxFilter = | "all" | "mention" + | "thread" | "needs_action" | "activity" | "agent_activity" @@ -37,6 +42,11 @@ export type InboxItem = { timestampLabel: string; }; +export type InboxTypeLabel = { + text: string; + channelLabel: string | null; +}; + export type InboxReply = { authorLabel: string; avatarUrl: string | null; @@ -60,6 +70,8 @@ export type InboxGroup = { items: InboxItem[]; }; +type InboxChannel = Pick; + const listTimeFormatter = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", @@ -160,24 +172,92 @@ function categoryLabelFor(category: FeedItemCategory) { : "Activity"; } -export function formatInboxTypeLabel(item: InboxItem) { +export function isThreadActivityItem(item: FeedItem) { + if (item.category !== "activity") { + return false; + } + + const thread = getThreadReference(item.tags); + return thread.parentId !== null && !isBroadcastReply(item.tags); +} + +function activityHeadline(item: FeedItem) { + return feedHeadline(item); +} + +function resolveItemChannel( + item: FeedItem, + channelById: ReadonlyMap, +) { + const channel = item.channelId ? channelById.get(item.channelId) : undefined; + const name = item.channelName?.trim() || channel?.name.trim() || null; + + return { + name, + type: item.channelType ?? channel?.channelType, + }; +} + +function resolveGroupChannel( + primaryItem: FeedItem, + groupItems: FeedItem[], + channelById: ReadonlyMap, +) { + for (const candidate of [primaryItem, ...groupItems]) { + const channel = resolveItemChannel(candidate, channelById); + if (channel.name || channel.type) { + return channel; + } + } + + return resolveItemChannel(primaryItem, channelById); +} + +export function getInboxTypeLabel(item: InboxItem): InboxTypeLabel { const channelName = item.channelLabel; - const channelSuffix = channelName ? ` in #${channelName}` : ""; if (item.item.channelType === "dm") { - return item.senderLabel ? `DM from ${item.senderLabel}` : "DM"; + return { + text: item.senderLabel ? `DM from ${item.senderLabel}` : "DM", + channelLabel: null, + }; + } + + const primaryCategory = item.item.category; + if (primaryCategory === "mention") { + return { + text: channelName ? "Mentioned in" : "Mentioned", + channelLabel: channelName, + }; } - const category = item.categories[0] ?? item.item.category; - if (category === "mention") { - return channelName ? `Mentioned in #${channelName}` : "Mentioned"; + if (primaryCategory === "needs_action") { + return { + text: channelName ? "Needs action in" : "Needs action", + channelLabel: channelName, + }; } - if (category === "needs_action") { - return channelName ? `Needs action in #${channelName}` : "Needs action"; + if (isThreadActivityItem(item.item)) { + return { + text: channelName ? "Thread in" : "Thread", + channelLabel: channelName, + }; } - return `${feedHeadline(item.item)}${channelSuffix}`; + return { + text: channelName + ? `${activityHeadline(item.item)} in` + : activityHeadline(item.item), + channelLabel: channelName, + }; +} + +export function formatInboxTypeLabel(item: InboxItem) { + const label = getInboxTypeLabel(item); + return label.channelLabel + ? `${label.text} #${label.channelLabel}` + : label.text; } function categoryPriority(category: FeedItemCategory) { @@ -262,10 +342,12 @@ export function groupInboxItems(items: InboxItem[]): InboxGroup[] { } export function buildInboxItems({ + channels, currentPubkey, feed, profiles, }: { + channels?: InboxChannel[]; currentPubkey?: string; feed?: HomeFeedResponse; profiles?: UserProfileLookup; @@ -292,6 +374,9 @@ export function buildInboxItems({ category: "agent_activity" as const, })), ]; + const channelById = new Map( + (channels ?? []).map((channel) => [channel.id, channel]), + ); const threadGroups = new Map< string, @@ -325,7 +410,7 @@ export function buildInboxItems({ const latestItem = group.items.reduce((latest, current) => current.createdAt > latest.createdAt ? current : latest, ); - const item = group.rootItem ?? latestItem; + const item = latestItem; const categories = [ ...new Set(group.items.map((groupItem) => groupItem.category)), ].sort((left, right) => categoryPriority(left) - categoryPriority(right)); @@ -338,13 +423,19 @@ export function buildInboxItems({ const subject = feedHeadline(item); const preview = feedPreview(item); const mentionNames = resolveMentionNames(item.tags, profiles) ?? []; - const channelLabel = item.channelName.trim() || null; + const groupChannel = resolveGroupChannel(item, group.items, channelById); + const channelLabel = groupChannel.name; + const displayItem: FeedItem = { + ...item, + channelName: channelLabel ?? item.channelName, + channelType: item.channelType ?? groupChannel.type, + }; const categoryLabel = categoryLabelFor(categories[0] ?? item.category); return { avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null, id: item.id, - item, + item: displayItem, categories, categoryLabel, channelLabel, diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index e6370c57d..d3bb9efa3 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { getContextMessageDepth, getReactionTargetId, + isInboxThreadContextEvent, matchesInboxFilter, } from "./inboxViewHelpers.ts"; @@ -29,6 +30,54 @@ test("matchesInboxFilter is false when the category is absent", () => { assert.equal(matchesInboxFilter({ categories: [] }, "mentions"), false); }); +test("matchesInboxFilter matches thread rows by thread tags", () => { + assert.equal( + matchesInboxFilter( + { + categories: ["activity"], + item: { + id: "reply", + kind: 9, + pubkey: "author", + content: "reply", + createdAt: 2, + channelId: "channel", + channelName: "bugs", + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "parent", "", "reply"], + ], + category: "activity", + }, + }, + "thread", + ), + true, + ); + + assert.equal( + matchesInboxFilter( + { + categories: ["activity"], + item: { + id: "root", + kind: 9, + pubkey: "author", + content: "root", + createdAt: 1, + channelId: "channel", + channelName: "bugs", + tags: [["h", "channel"]], + category: "activity", + }, + }, + "thread", + ), + false, + ); +}); + // --- getReactionTargetId --- test("getReactionTargetId returns the last e-tag target id", () => { @@ -108,3 +157,76 @@ test("getContextMessageDepth does not loop forever on a cycle", () => { // From a: hop to b (depth 1); b's parent is a, already seen -> stop. assert.equal(getContextMessageDepth(a, map), 1); }); + +// --- isInboxThreadContextEvent --- + +function channelEvent(id, tags = []) { + return { + id, + pubkey: "x", + created_at: 0, + kind: 9, + tags: [["h", "channel-a"], ...tags], + content: "", + sig: "", + }; +} + +test("isInboxThreadContextEvent rejects stale events from a different thread", () => { + const selection = { + selectedChannelId: "channel-a", + selectedEventId: "selected-reply", + selectedParentId: "selected-parent", + selectedThreadRootId: "selected-root", + }; + + assert.equal( + isInboxThreadContextEvent( + channelEvent("old-root", [["e", "old-root", "", "root"]]), + selection, + ), + false, + ); + assert.equal( + isInboxThreadContextEvent( + channelEvent("old-reply", [ + ["e", "old-root", "", "root"], + ["e", "old-parent", "", "reply"], + ]), + selection, + ), + false, + ); +}); + +test("isInboxThreadContextEvent keeps selected thread root, parent, selected event, and descendants", () => { + const selection = { + selectedChannelId: "channel-a", + selectedEventId: "selected-reply", + selectedParentId: "selected-parent", + selectedThreadRootId: "selected-root", + }; + + assert.equal( + isInboxThreadContextEvent(channelEvent("selected-root"), selection), + true, + ); + assert.equal( + isInboxThreadContextEvent(channelEvent("selected-parent"), selection), + true, + ); + assert.equal( + isInboxThreadContextEvent(channelEvent("selected-reply"), selection), + true, + ); + assert.equal( + isInboxThreadContextEvent( + channelEvent("descendant", [ + ["e", "selected-root", "", "root"], + ["e", "selected-reply", "", "reply"], + ]), + selection, + ), + true, + ); +}); diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 2793b6fc5..0991dcca0 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -1,15 +1,27 @@ -import type { InboxFilter } from "@/features/home/lib/inbox"; -import { getThreadReference } from "@/features/messages/lib/threading"; -import type { RelayEvent } from "@/shared/api/types"; +import { + isThreadActivityItem, + type InboxFilter, +} from "@/features/home/lib/inbox"; +import { + getChannelIdFromTags, + getThreadReference, +} from "@/features/messages/lib/threading"; +import type { FeedItem, RelayEvent } from "@/shared/api/types"; export function matchesInboxFilter( - item: { categories: InboxFilter[] }, + item: { categories: readonly string[]; item?: FeedItem }, filter: InboxFilter, ) { if (filter === "all") { return true; } + if (filter === "thread") { + return item.item + ? isThreadActivityItem(item.item) + : item.categories.includes(filter); + } + return item.categories.includes(filter); } @@ -30,6 +42,46 @@ export function getContextMessageDepth( return depth; } +export function isInboxThreadContextEvent( + event: RelayEvent, + selection: { + selectedChannelId: string | null; + selectedEventId: string; + selectedParentId: string | null; + selectedThreadRootId: string | null; + }, +): boolean { + if ( + selection.selectedChannelId && + getChannelIdFromTags(event.tags) !== selection.selectedChannelId + ) { + return false; + } + + if (event.id === selection.selectedEventId) { + return true; + } + + if ( + selection.selectedThreadRootId && + event.id === selection.selectedThreadRootId + ) { + return true; + } + + if (selection.selectedParentId && event.id === selection.selectedParentId) { + return true; + } + + const thread = getThreadReference(event.tags); + return ( + (selection.selectedThreadRootId !== null && + (thread.rootId === selection.selectedThreadRootId || + thread.parentId === selection.selectedThreadRootId)) || + thread.parentId === selection.selectedEventId + ); +} + export function getReactionTargetId(tags: string[][]) { for (let index = tags.length - 1; index >= 0; index -= 1) { const tag = tags[index]; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index fd5085bb3..b43696b51 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -15,7 +15,6 @@ import { getReactionTargetId, matchesInboxFilter, } from "@/features/home/lib/inboxViewHelpers"; -import { useFeedItemState } from "@/features/home/useFeedItemState"; import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; import { @@ -79,6 +78,7 @@ export function HomeView({ homeInboxWidthPx > 0 && homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; const [filter, setFilter] = React.useState("all"); + const [unreadOnly, setUnreadOnly] = React.useState(false); // Explicit selections are mirrored to the URL (`?item=`), so back/forward // restores the detail pane each history entry was showing and reloads // restore it from the URL. Default/automatic selection stays local-only — @@ -117,13 +117,16 @@ export function HomeView({ handleInboxListWidthReset, inboxListWidthPx, } = useResizableInboxListWidth(); - const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); const { getChannelReadAt, + getThreadReadAt, + feedItemState, markChannelRead, - markChannelUnread, + markThreadRead, readStateVersion, } = useAppShell(); + const { doneSet, markDone, markUnread, undoDone, undoUnread, unreadSet } = + feedItemState; const feedItems = React.useMemo( () => feed @@ -182,26 +185,37 @@ export function HomeView({ const inboxItems = React.useMemo( () => buildInboxItems({ + channels, currentPubkey, feed, profiles: feedProfiles, }), - [currentPubkey, feed, feedProfiles], + [channels, currentPubkey, feed, feedProfiles], ); const { effectiveDoneSet, markItemRead, markItemUnread } = useHomeInboxReadState({ items: inboxItems, getChannelReadAt, + getThreadReadAt, readStateVersion, localDoneSet: doneSet, + localUnreadSet: unreadSet, markChannelRead, - markChannelUnread, + markThreadRead, markDoneLocal: markDone, + markUnreadLocal: markUnread, undoDoneLocal: undoDone, + undoUnreadLocal: undoUnread, }); const filteredItems = React.useMemo(() => { - return inboxItems.filter((item) => matchesInboxFilter(item, filter)); - }, [filter, inboxItems]); + return inboxItems.filter( + (item) => + matchesInboxFilter(item, filter) && + (!unreadOnly || + !effectiveDoneSet.has(item.id) || + item.id === selectedItemId), + ); + }, [effectiveDoneSet, filter, inboxItems, selectedItemId, unreadOnly]); const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? null; const contextMessages = React.useMemo(() => { @@ -412,13 +426,17 @@ export function HomeView({ filter={filter} items={filteredItems} onFilterChange={setFilter} + onMarkRead={markItemRead} + onMarkUnread={markItemUnread} onSelect={(itemId) => { handleUserSelectItem(itemId); markItemRead(itemId); }} + onUnreadOnlyChange={setUnreadOnly} reminderPubkey={currentPubkey} selectedId={selectedItemId} showRightDivider={showListPane && showDetailPane} + unreadOnly={unreadOnly} /> ) : null} diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 79ca0ee4b..9b32a57ef 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -310,13 +310,11 @@ export function InboxDetailPane({ -
+
- {isThreadContextLoading ? ( -
- Loading context... -
- ) : null} {displayMessages.map((message, index) => ( {index === 1 ? ( diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 670c95b46..5d0e50a7a 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,17 +1,28 @@ -import { ChevronDown } from "lucide-react"; +import { CheckCircle2, ChevronDown, CircleDot } from "lucide-react"; import * as React from "react"; import { - formatInboxTypeLabel, + getInboxTypeLabel, type InboxFilter, type InboxItem, + type InboxTypeLabel, } from "@/features/home/lib/inbox"; import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/shared/ui/context-menu"; import { Markdown } from "@/shared/ui/markdown"; +import { + MENTION_CHIP_BASE_CLASSES, + MESSAGE_MARKDOWN_CLASS, +} from "@/shared/ui/mentionChip"; import { DropdownMenu, DropdownMenuContent, @@ -19,28 +30,71 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Switch } from "@/shared/ui/switch"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "all", label: "All" }, { value: "mention", label: "Mentions" }, + { value: "thread", label: "Threads" }, { value: "needs_action", label: "Needs Action" }, { value: "activity", label: "Activity" }, { value: "agent_activity", label: "Agents" }, { value: "reminders", label: "Reminders" }, ]; +function ActivityLabel({ + isDone, + isActionRequired, + label, +}: { + isDone: boolean; + isActionRequired: boolean; + label: InboxTypeLabel; +}) { + return ( +
+ {label.text} + {label.channelLabel ? ( + + #{label.channelLabel} + + ) : null} +
+ ); +} + type InboxListPaneProps = { doneSet: ReadonlySet; filter: InboxFilter; items: InboxItem[]; onFilterChange: (filter: InboxFilter) => void; + onMarkRead: (itemId: string) => void; + onMarkUnread: (itemId: string) => void; onSelect: (itemId: string) => void; + onUnreadOnlyChange: (checked: boolean) => void; selectedId: string | null; showRightDivider?: boolean; dueReminderCount: number; reminderPubkey?: string; + unreadOnly: boolean; }; export function InboxListPane({ @@ -48,98 +102,112 @@ export function InboxListPane({ filter, items, onFilterChange, + onMarkRead, + onMarkUnread, onSelect, + onUnreadOnlyChange, selectedId, showRightDivider = false, dueReminderCount, reminderPubkey, + unreadOnly, }: InboxListPaneProps) { const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; const scrollRef = React.useRef(null); - const renderItem = (item: InboxItem) => { + const renderItem = (item: InboxItem, index: number) => { const isSelected = item.id === selectedId; const isDone = doneSet.has(item.id); - const typeLabel = formatInboxTypeLabel(item); + const typeLabel = getInboxTypeLabel(item); - return ( + const row = ( ); + + return ( + + {row} + + {isDone ? ( + onMarkUnread(item.id)}> + + Mark unread + + ) : ( + onMarkRead(item.id)}> + + Mark as read + + )} + + + ); }; return ( @@ -151,59 +219,76 @@ export function InboxListPane({ >
- {/* Cap to the list-column width so the right-aligned dropdown stays - put when the pane goes full-width in reminders mode. */} -
- - - - - - - onFilterChange(value as InboxFilter) - } - value={filter} - > - {FILTER_OPTIONS.map((option) => ( - - - {option.label} - {option.value === "reminders" && - dueReminderCount > 0 ? ( - - {dueReminderCount} - - ) : null} +
+ +
+ + + + + + + onFilterChange(value as InboxFilter) + } + value={filter} + > + {FILTER_OPTIONS.map((option) => ( + + + {option.label} + {option.value === "reminders" && + dueReminderCount > 0 ? ( + + {dueReminderCount} + + ) : null} + + + ))} + + + +
@@ -227,16 +312,18 @@ export function InboxListPane({

- No messages found + {unreadOnly ? "No unread messages" : "No messages found"}

- Switch back to all mail to see more messages. + {unreadOnly + ? "Turn off the unread filter to see read messages." + : "Switch back to all mail to see more messages."}

) : ( item.id} items={items} renderItem={renderItem} diff --git a/desktop/src/features/home/useFeedItemState.ts b/desktop/src/features/home/useFeedItemState.ts index affb91b4c..9dc574cca 100644 --- a/desktop/src/features/home/useFeedItemState.ts +++ b/desktop/src/features/home/useFeedItemState.ts @@ -1,12 +1,17 @@ import * as React from "react"; const DONE_STORAGE_KEY = "buzz-home-feed-done.v1"; +const UNREAD_STORAGE_KEY = "buzz-home-feed-unread.v1"; const MAX_ITEMS = 500; function doneStorageKey(pubkey: string) { return `${DONE_STORAGE_KEY}:${pubkey}`; } +function unreadStorageKey(pubkey: string) { + return `${UNREAD_STORAGE_KEY}:${pubkey}`; +} + function readStoredIds(key: string): string[] { if (typeof window === "undefined") return []; const raw = window.localStorage.getItem(key); @@ -35,24 +40,45 @@ export function useFeedItemState(pubkey: string | undefined) { const [doneIds, setDoneIds] = React.useState(() => readStoredIds(key), ); + const [unreadIds, setUnreadIds] = React.useState(() => + readStoredIds(unreadStorageKey(normalizedPubkey)), + ); React.useEffect(() => { setDoneIds(readStoredIds(doneStorageKey(normalizedPubkey))); + setUnreadIds(readStoredIds(unreadStorageKey(normalizedPubkey))); }, [normalizedPubkey]); React.useEffect(() => { writeStoredIds(doneStorageKey(normalizedPubkey), doneIds); }, [normalizedPubkey, doneIds]); + React.useEffect(() => { + writeStoredIds(unreadStorageKey(normalizedPubkey), unreadIds); + }, [normalizedPubkey, unreadIds]); + const doneSet = React.useMemo(() => new Set(doneIds), [doneIds]); + const unreadSet = React.useMemo(() => new Set(unreadIds), [unreadIds]); const markDone = React.useCallback((id: string) => { setDoneIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + setUnreadIds((prev) => prev.filter((v) => v !== id)); }, []); const undoDone = React.useCallback((id: string) => { setDoneIds((prev) => prev.filter((v) => v !== id)); }, []); - return { doneSet, markDone, undoDone }; + const markUnread = React.useCallback((id: string) => { + setDoneIds((prev) => prev.filter((v) => v !== id)); + setUnreadIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + }, []); + + const undoUnread = React.useCallback((id: string) => { + setUnreadIds((prev) => prev.filter((v) => v !== id)); + }, []); + + return { doneSet, markDone, markUnread, undoDone, undoUnread, unreadSet }; } + +export type FeedItemState = ReturnType; diff --git a/desktop/src/features/home/useHomeInboxReadState.ts b/desktop/src/features/home/useHomeInboxReadState.ts index 75134abed..386858cdf 100644 --- a/desktop/src/features/home/useHomeInboxReadState.ts +++ b/desktop/src/features/home/useHomeInboxReadState.ts @@ -1,49 +1,74 @@ import * as React from "react"; import type { InboxItem } from "@/features/home/lib/inbox"; +import { + getThreadReference, + isThreadReply, +} from "@/features/messages/lib/threading"; type UseHomeInboxReadStateOptions = { /** Inbox items to project read-state across. */ items: InboxItem[]; /** NIP-RS read marker resolver for channel-backed items (unix seconds, or null when unknown). */ getChannelReadAt: (channelId: string) => number | null; + /** NIP-RS read marker resolver for thread-backed items (unix seconds, or null when unknown). */ + getThreadReadAt: (rootId: string) => number | null; /** Invalidation signal for the channel-marker projection. */ readStateVersion: number; /** Local fallback "done" set (used only for items with no channelId). */ localDoneSet: ReadonlySet; + /** Per-item local unread override for inbox rows. */ + localUnreadSet: ReadonlySet; /** Mark a channel read up to the given ISO timestamp (NIP-RS). */ markChannelRead: ( channelId: string, readAt: string | null | undefined, ) => void; - /** Mark a channel unread locally for the current session. */ - markChannelUnread: (channelId: string) => void; + /** Advance the thread read marker to the given unix-seconds timestamp. */ + markThreadRead: (rootId: string, timestamp: number) => void; /** Local fallback: mark a non-channel item done. */ markDoneLocal: (id: string) => void; + /** Local inbox row override: mark an item unread without touching the channel. */ + markUnreadLocal: (id: string) => void; /** Local fallback: undo a non-channel item done. */ undoDoneLocal: (id: string) => void; + /** Clear the local inbox row unread override. */ + undoUnreadLocal: (id: string) => void; }; +const EMPTY_ITEM_SET: ReadonlySet = new Set(); + +function getInboxThreadRootId(item: InboxItem): string | null { + if (!isThreadReply(item.item.tags)) { + return null; + } + + return getThreadReference(item.item.tags).rootId; +} + /** * Projects Home inbox read-state from the shared NIP-RS read marker, with * the local `useFeedItemState` done-set as a fallback for items that don't * belong to a channel (reminders etc.). * - * "Mark as read/unread" actions on channel-backed items are routed through - * `markChannelRead`/`markChannelUnread` so the sidebar, home badge, and any - * other surfaces consuming the same ReadStateManager stay in lockstep. - * Caveat: NIP-RS channel read markers are monotonic, so marking an older item - * unread is an in-session local affordance rather than synced state. + * "Mark as read" on channel-backed items is routed through `markChannelRead`; + * thread rows use their own `thread:` marker so they do not affect the + * sidebar channel dot. "Mark unread" is item-local: it only reopens the + * specific inbox row and must not light up the channel. */ export function useHomeInboxReadState({ items, getChannelReadAt, + getThreadReadAt, readStateVersion, localDoneSet, + localUnreadSet = EMPTY_ITEM_SET, markChannelRead, - markChannelUnread, + markThreadRead, markDoneLocal, + markUnreadLocal, undoDoneLocal, + undoUnreadLocal, }: UseHomeInboxReadStateOptions) { const itemById = React.useMemo( () => new Map(items.map((item) => [item.id, item])), @@ -54,7 +79,20 @@ export function useHomeInboxReadState({ const effectiveDoneSet = React.useMemo>(() => { const result = new Set(); for (const item of items) { + if (localUnreadSet.has(item.id)) { + continue; + } + const channelId = item.item.channelId; + const threadRootId = getInboxThreadRootId(item); + if (threadRootId) { + const readAt = getThreadReadAt(threadRootId); + if (readAt !== null && item.latestActivityAt <= readAt) { + result.add(item.id); + } + continue; + } + if (channelId) { const readAt = getChannelReadAt(channelId); if (readAt !== null && item.latestActivityAt <= readAt) { @@ -67,11 +105,25 @@ export function useHomeInboxReadState({ } } return result; - }, [getChannelReadAt, items, localDoneSet, readStateVersion]); + }, [ + getChannelReadAt, + getThreadReadAt, + items, + localDoneSet, + localUnreadSet, + readStateVersion, + ]); const markItemRead = React.useCallback( (itemId: string) => { + undoUnreadLocal(itemId); const item = itemById.get(itemId); + const threadRootId = item ? getInboxThreadRootId(item) : null; + if (item && threadRootId) { + markThreadRead(threadRootId, item.latestActivityAt); + return; + } + const channelId = item?.item.channelId ?? null; if (item && channelId) { markChannelRead( @@ -82,20 +134,15 @@ export function useHomeInboxReadState({ } markDoneLocal(itemId); }, - [itemById, markChannelRead, markDoneLocal], + [itemById, markChannelRead, markDoneLocal, markThreadRead, undoUnreadLocal], ); const markItemUnread = React.useCallback( (itemId: string) => { - const item = itemById.get(itemId); - const channelId = item?.item.channelId ?? null; - if (item && channelId) { - markChannelUnread(channelId); - return; - } undoDoneLocal(itemId); + markUnreadLocal(itemId); }, - [itemById, markChannelUnread, undoDoneLocal], + [markUnreadLocal, undoDoneLocal], ); return { effectiveDoneSet, markItemRead, markItemUnread }; diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts index 95766ce4b..5e830b5c0 100644 --- a/desktop/src/features/home/useInboxThreadContext.ts +++ b/desktop/src/features/home/useInboxThreadContext.ts @@ -1,10 +1,8 @@ import * as React from "react"; +import { isInboxThreadContextEvent } from "@/features/home/lib/inboxViewHelpers"; import { relayEventFromFeedItem } from "@/features/home/lib/inbox"; -import { - getChannelIdFromTags, - getThreadReference, -} from "@/features/messages/lib/threading"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { relayClient } from "@/shared/api/relayClient"; import { getEventById } from "@/shared/api/tauri"; import type { FeedItem, RelayEvent } from "@/shared/api/types"; @@ -30,13 +28,6 @@ function getThreadRootId(event: RelayEvent): string { return thread.rootId ?? thread.parentId ?? event.id; } -function isSameChannel(event: RelayEvent, channelId: string | null): boolean { - if (!channelId) { - return true; - } - return getChannelIdFromTags(event.tags) === channelId; -} - export function useInboxThreadContext( item: FeedItem | null, channelMessages: RelayEvent[] | undefined, @@ -78,12 +69,18 @@ export function useInboxThreadContext( setIsLoading(true); try { + const selection = { + selectedChannelId, + selectedEventId: targetEvent.id, + selectedParentId, + selectedThreadRootId: threadRootId, + }; const eventIds = new Set([threadRootId]); if (selectedParentId) { eventIds.add(selectedParentId); } - const ancestorEvents = await Promise.all( + const ancestorEventsPromise = Promise.all( [...eventIds] .filter((eventId) => eventId !== targetEvent.id) .map(async (eventId) => { @@ -95,9 +92,9 @@ export function useInboxThreadContext( }), ); - const descendantEvents = + const descendantEventsPromise = selectedChannelId && threadRootId - ? await relayClient + ? relayClient .fetchEvents({ "#e": [threadRootId], "#h": [selectedChannelId], @@ -105,7 +102,11 @@ export function useInboxThreadContext( limit: THREAD_CONTEXT_LIMIT, }) .catch(() => []) - : []; + : Promise.resolve([]); + const [ancestorEvents, descendantEvents] = await Promise.all([ + ancestorEventsPromise, + descendantEventsPromise, + ]); if (isCancelled) { return; @@ -115,7 +116,7 @@ export function useInboxThreadContext( dedupeEvents( [...ancestorEvents, ...descendantEvents].filter( (event): event is RelayEvent => - event !== null && isSameChannel(event, selectedChannelId), + event !== null && isInboxThreadContextEvent(event, selection), ), ), ); @@ -144,25 +145,28 @@ export function useInboxThreadContext( } const localContext = (channelMessages ?? []).filter((event) => { - if (!isSameChannel(event, selectedChannelId)) { - return false; - } - - if (event.id === selectedEvent.id) { - return true; - } - - const thread = getThreadReference(event.tags); - return ( - event.id === selectedThreadRootId || - event.id === selectedParentId || - thread.rootId === selectedThreadRootId || - thread.parentId === selectedThreadRootId || - thread.parentId === selectedEvent.id - ); + return isInboxThreadContextEvent(event, { + selectedChannelId, + selectedEventId: selectedEvent.id, + selectedParentId, + selectedThreadRootId, + }); }); - return dedupeEvents([selectedEvent, ...fetchedEvents, ...localContext]); + const currentFetchedEvents = fetchedEvents.filter((event) => + isInboxThreadContextEvent(event, { + selectedChannelId, + selectedEventId: selectedEvent.id, + selectedParentId, + selectedThreadRootId, + }), + ); + + return dedupeEvents([ + selectedEvent, + ...currentFetchedEvents, + ...localContext, + ]); }, [ channelMessages, fetchedEvents, diff --git a/desktop/src/features/home/useResizableInboxListWidth.ts b/desktop/src/features/home/useResizableInboxListWidth.ts index a3d6c1aad..2c896a091 100644 --- a/desktop/src/features/home/useResizableInboxListWidth.ts +++ b/desktop/src/features/home/useResizableInboxListWidth.ts @@ -1,6 +1,6 @@ import * as React from "react"; -const INBOX_LIST_DEFAULT_WIDTH_PX = 320; +const INBOX_LIST_DEFAULT_WIDTH_PX = 365; export const INBOX_COLUMN_MIN_WIDTH_PX = 300; export const INBOX_SINGLE_COLUMN_BREAKPOINT_PX = INBOX_COLUMN_MIN_WIDTH_PX * 2; const INBOX_LIST_MAX_WIDTH_PX = 520; diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index f942aaa77..4a18d0264 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -17,6 +17,11 @@ export function isBroadcastReply(tags: string[][]): boolean { return tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1"); } +export function isThreadReply(tags: string[][]): boolean { + const ref = getThreadReference(tags); + return ref.parentId !== null && !isBroadcastReply(tags); +} + export function getThreadReference(tags: string[][]): ThreadReference { const eventTags = getEventTags(tags); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 9f7d37b78..4a88f3645 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -1,9 +1,13 @@ import * as React from "react"; import { useHomeFeedQuery } from "@/features/home/hooks"; +import { + getThreadReference, + isThreadReply, +} from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { HomeFeedResponse } from "@/shared/api/types"; +import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, @@ -31,6 +35,7 @@ export type { DesktopNotificationPermissionState } from "./lib/desktop"; // slotAlertsEnabled, no singleSound/soundEnabled) — v1 values are abandoned. const NOTIFICATION_SETTINGS_STORAGE_KEY = "buzz-notification-settings.v2"; const HOME_FEED_SEEN_MAX_ITEMS = 500; +const EMPTY_FEED_ID_SET: ReadonlySet = new Set(); export type NotificationSettings = { desktopEnabled: boolean; @@ -359,6 +364,9 @@ export function useHomeFeedNotificationState( highPriorityChannelIds: ReadonlySet, profiles?: UserProfileLookup, mutedChannelIds?: ReadonlySet, + localUnreadFeedIds: ReadonlySet = EMPTY_FEED_ID_SET, + extraInboxItems: readonly FeedItem[] = [], + getThreadReadAt: (rootId: string) => number | null = () => null, ) { useFeedDesktopNotifications( feed, @@ -373,8 +381,11 @@ export function useHomeFeedNotificationState( readStoredSeenFeedIds(normalizedPubkey), ); const currentFeedItems = React.useMemo( - () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), - [feed], + () => + feed + ? [...feed.feed.mentions, ...feed.feed.needsAction, ...extraInboxItems] + : [...extraInboxItems], + [extraInboxItems, feed], ); const currentFeedIds = React.useMemo( () => currentFeedItems.map((item) => item.id), @@ -405,7 +416,7 @@ export function useHomeFeedNotificationState( // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt return React.useMemo(() => { const zero = { homeBadgeCount: 0, homeBadgeCountExcludingHighPriority: 0 }; - if (!settings.homeBadgeEnabled || isHomeActive) { + if (!settings.homeBadgeEnabled) { return zero; } @@ -417,6 +428,10 @@ export function useHomeFeedNotificationState( let total = 0; let excludingHighPriority = 0; for (const item of currentFeedItems) { + const isLocallyUnread = localUnreadFeedIds.has(item.id); + if (isHomeActive && !isLocallyUnread) { + continue; + } if ( item.channelId && mutedChannelIds?.has(item.channelId) && @@ -424,8 +439,19 @@ export function useHomeFeedNotificationState( ) { continue; } + const threadRootId = isThreadReply(item.tags) + ? getThreadReference(item.tags).rootId + : null; let isUnread: boolean; - if (item.channelId) { + if (isLocallyUnread) { + isUnread = true; + } else if (threadRootId) { + const readAt = getThreadReadAt(threadRootId); + isUnread = + readAt !== null + ? item.createdAt > readAt + : !seenFeedIdSet.has(item.id); + } else if (item.channelId) { const readAt = getChannelReadAt(item.channelId); isUnread = readAt !== null @@ -436,7 +462,13 @@ export function useHomeFeedNotificationState( } if (!isUnread) continue; total++; - if (!(item.channelId && highPriorityChannelIds.has(item.channelId))) { + if ( + !( + threadRootId === null && + item.channelId && + highPriorityChannelIds.has(item.channelId) + ) + ) { excludingHighPriority++; } } @@ -447,8 +479,10 @@ export function useHomeFeedNotificationState( }, [ currentFeedItems, getChannelReadAt, + getThreadReadAt, highPriorityChannelIds, isHomeActive, + localUnreadFeedIds, mutedChannelIds, readStateVersion, seenFeedIds, diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 996c54154..9073e457e 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -30,6 +30,7 @@ export function isRelayConnectionDegraded(state: ConnectionState): boolean { } export type RelaySubscriptionFilter = { + ids?: string[]; kinds: number[]; limit: number; authors?: string[]; diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index c4dad5199..014c14e2d 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -896,6 +896,39 @@ color: hsl(var(--primary)); } +.message-markdown .mention-chip.inbox-channel-chip { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground) / 0.82); +} + +.message-markdown.inbox-preview-markdown { + --inline-chip-padding-block-start: 0.125rem; + --inline-chip-padding-block-end: 0.0625rem; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + padding-bottom: 0.25rem; +} + +.message-markdown.inbox-preview-markdown > p { + display: inline; +} + +.message-markdown.inbox-preview-markdown br { + display: none; +} + +.message-markdown.inbox-preview-markdown .mention-chip { + display: inline; + overflow: visible; + overflow-wrap: normal; + text-overflow: clip; + vertical-align: baseline; + white-space: nowrap; + word-break: normal; +} + .message-markdown .mention-chip-prefix { display: inline-block; line-height: 1; From 66d905a89bf7449de4baf123468f5ea722aab886 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 18 Jun 2026 18:46:41 +0100 Subject: [PATCH 02/31] Add inbox row hover actions --- desktop/src/features/home/ui/HomeView.tsx | 22 ++ .../src/features/home/ui/InboxListPane.tsx | 215 +++++++++++++----- 2 files changed, 179 insertions(+), 58 deletions(-) diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index b43696b51..c260b214b 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -40,6 +40,7 @@ import { countDueReminders, useRemindersQuery, } from "@/features/reminders/hooks"; +import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { HomeFeedResponse } from "@/shared/api/types"; import { KIND_REACTION } from "@/shared/constants/kinds"; @@ -108,6 +109,7 @@ export function HomeView({ ); const [isDeletingMessage, setIsDeletingMessage] = React.useState(false); const [isSendingReply, setIsSendingReply] = React.useState(false); + const { activeReminderEventIds, openReminder } = useRemindLater(); const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< Record >({}); @@ -421,6 +423,7 @@ export function HomeView({ > {showListPane ? ( { + const channelId = item.item.channelId; + if (!channelId) { + return; + } + onOpenContext(channelId, item.id); + }} + onRemindLater={(item) => { + const channelId = item.item.channelId; + if (!channelId) { + return; + } + openReminder({ + authorPubkey: item.item.pubkey, + channelId, + eventId: item.id, + preview: item.preview.slice(0, 100), + }); + }} onSelect={(itemId) => { handleUserSelectItem(itemId); markItemRead(itemId); diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 5d0e50a7a..05c432296 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,10 @@ -import { CheckCircle2, ChevronDown, CircleDot } from "lucide-react"; +import { + CheckCircle2, + ChevronDown, + CircleDot, + Clock, + ExternalLink, +} from "lucide-react"; import * as React from "react"; import { @@ -31,6 +37,7 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { Switch } from "@/shared/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; @@ -82,12 +89,15 @@ function ActivityLabel({ } type InboxListPaneProps = { + activeReminderEventIds?: ReadonlySet; doneSet: ReadonlySet; filter: InboxFilter; items: InboxItem[]; onFilterChange: (filter: InboxFilter) => void; onMarkRead: (itemId: string) => void; onMarkUnread: (itemId: string) => void; + onOpenDirect: (item: InboxItem) => void; + onRemindLater: (item: InboxItem) => void; onSelect: (itemId: string) => void; onUnreadOnlyChange: (checked: boolean) => void; selectedId: string | null; @@ -98,12 +108,15 @@ type InboxListPaneProps = { }; export function InboxListPane({ + activeReminderEventIds, doneSet, filter, items, onFilterChange, onMarkRead, onMarkUnread, + onOpenDirect, + onRemindLater, onSelect, onUnreadOnlyChange, selectedId, @@ -119,75 +132,120 @@ export function InboxListPane({ const renderItem = (item: InboxItem, index: number) => { const isSelected = item.id === selectedId; const isDone = doneSet.has(item.id); + const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false; + const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = getInboxTypeLabel(item); const row = ( - -
+ {isDone ? ( + onMarkUnread(item.id)} + > + + + ) : ( + onMarkRead(item.id)} + > + + )} - > - + onOpenDirect(item)} + > + + + onRemindLater(item)} + > + +
- +
); return ( @@ -335,3 +393,44 @@ export function InboxListPane({ ); } + +function InboxRowActionButton({ + active = false, + children, + disabled = false, + label, + onClick, +}: { + active?: boolean; + children: React.ReactNode; + disabled?: boolean; + label: string; + onClick: () => void; +}) { + return ( + + + + + {label} + + ); +} From 084e71809bbf701848e4675338724eafa4d7afb1 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 18 Jun 2026 19:44:41 +0100 Subject: [PATCH 03/31] Match inbox tray icons --- desktop/src/features/home/ui/InboxListPane.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 05c432296..4ea2fcdb1 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,10 +1,4 @@ -import { - CheckCircle2, - ChevronDown, - CircleDot, - Clock, - ExternalLink, -} from "lucide-react"; +import { ChevronDown, Clock, ExternalLink, MailOpen } from "lucide-react"; import * as React from "react"; import { @@ -213,14 +207,14 @@ export function InboxListPane({ label="Mark unread" onClick={() => onMarkUnread(item.id)} > - + ) : ( onMarkRead(item.id)} > - + )} {isDone ? ( onMarkUnread(item.id)}> - + Mark unread ) : ( onMarkRead(item.id)}> - + Mark as read )} From 9dd8636c44683963c53796e0b331816d61fd303e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 19 Jun 2026 09:50:28 +0100 Subject: [PATCH 04/31] Rename home tab to inbox --- desktop/src/features/sidebar/ui/AppSidebar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9262d27e3..c491ac1cb 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -5,7 +5,7 @@ import { ArrowUp, Bot, FolderGit2, - Home, + Inbox, MessageCirclePlus, Zap, } from "lucide-react"; @@ -472,11 +472,11 @@ export function AppSidebar({ - - Home + + Inbox {homeBadgeCount > 0 ? ( Date: Fri, 19 Jun 2026 09:56:04 +0100 Subject: [PATCH 05/31] Polish inbox hover tray motion --- desktop/src/features/home/ui/InboxListPane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 4ea2fcdb1..940be427f 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -201,7 +201,7 @@ export function InboxListPane({
-
+
{isDone ? ( Date: Fri, 19 Jun 2026 09:58:46 +0100 Subject: [PATCH 06/31] Align inbox tray styling with message actions --- .../src/features/home/ui/InboxListPane.tsx | 80 ++++++++++--------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 940be427f..5dd1bed1b 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -201,43 +201,47 @@ export function InboxListPane({
-
- {isDone ? ( - onMarkUnread(item.id)} - > - - - ) : ( - onMarkRead(item.id)} - > - - - )} - onOpenDirect(item)} - > - - - onRemindLater(item)} - > - - +
+
+
+ {isDone ? ( + onMarkUnread(item.id)} + > + + + ) : ( + onMarkRead(item.id)} + > + + + )} + onOpenDirect(item)} + > + + + onRemindLater(item)} + > + + +
+
); @@ -407,7 +411,7 @@ function InboxRowActionButton({
-
+
{isDone ? ( Date: Fri, 19 Jun 2026 10:16:59 +0100 Subject: [PATCH 09/31] Try inbox header actions menu --- .../src/features/home/ui/InboxListPane.tsx | 126 ++++++++++++++---- 1 file changed, 102 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index c5b10af81..ed4da8af3 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,10 @@ -import { ChevronDown, Clock, ExternalLink, MailOpen } from "lucide-react"; +import { + Clock, + Ellipsis, + ExternalLink, + ListFilter, + MailOpen, +} from "lucide-react"; import * as React from "react"; import { @@ -16,6 +22,7 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from "@/shared/ui/context-menu"; import { Markdown } from "@/shared/ui/markdown"; @@ -30,6 +37,8 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Separator } from "@/shared/ui/separator"; import { Switch } from "@/shared/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -122,6 +131,18 @@ export function InboxListPane({ const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; const scrollRef = React.useRef(null); + const unreadVisibleItemCount = React.useMemo( + () => + items.reduce((count, item) => count + (doneSet.has(item.id) ? 0 : 1), 0), + [doneSet, items], + ); + const handleMarkAllRead = React.useCallback(() => { + for (const item of items) { + if (!doneSet.has(item.id)) { + onMarkRead(item.id); + } + } + }, [doneSet, items, onMarkRead]); const renderItem = (item: InboxItem, index: number) => { const isSelected = item.id === selectedId; @@ -261,6 +282,29 @@ export function InboxListPane({ Mark as read )} + + { + if (hasChannelTarget) { + onOpenDirect(item); + } + }} + > + + {hasChannelTarget ? "Open in channel" : "No channel link"} + + { + if (hasChannelTarget) { + onRemindLater(item); + } + }} + > + + {hasActiveReminder ? "Reminder set" : "Remind me later"} + ); @@ -276,46 +320,80 @@ export function InboxListPane({
- + + + + + +
+ + +
+ + +
+
- + onFilterChange(value as InboxFilter) From ec77b23804bd09d4f0f2550cbbc7fad0cc60e85a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 19 Jun 2026 10:23:54 +0100 Subject: [PATCH 10/31] Move inbox controls to right side --- .../src/features/home/ui/InboxListPane.tsx | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index ed4da8af3..6ca47f181 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -320,58 +320,58 @@ export function InboxListPane({
- - - - - -
-
- - -
-
-
+ + + + +
+ + +
+ + +
+ + + + + onFilterChange(value as InboxFilter) + } + value={filter} + > + {FILTER_OPTIONS.map((option) => ( + + + {option.label} + {option.value === "reminders" && + dueReminderCount > 0 ? ( + + {dueReminderCount} + + ) : null} + + + ))} + + + - - - - onFilterChange(value as InboxFilter) - } - value={filter} - > - {FILTER_OPTIONS.map((option) => ( - - - {option.label} - {option.value === "reminders" && - dueReminderCount > 0 ? ( - - {dueReminderCount} - - ) : null} - - - ))} - - -
From 3c6a7275cda484a1c52c57972f91a194a913c8ee Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 19 Jun 2026 10:27:17 +0100 Subject: [PATCH 12/31] Match inbox tray radius to message actions --- desktop/src/features/home/ui/InboxListPane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index f3ba76362..ba27ca4b9 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -223,7 +223,7 @@ export function InboxListPane({
-
+
{isDone ? ( Date: Fri, 19 Jun 2026 10:42:09 +0100 Subject: [PATCH 13/31] Place inbox options before filter --- .../src/features/home/ui/InboxListPane.tsx | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index ba27ca4b9..08852734f 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -321,56 +321,6 @@ export function InboxListPane({
- - - - - - - onFilterChange(value as InboxFilter) - } - value={filter} - > - {FILTER_OPTIONS.map((option) => ( - - - {option.label} - {option.value === "reminders" && - dueReminderCount > 0 ? ( - - {dueReminderCount} - - ) : null} - - - ))} - - - + + + + onFilterChange(value as InboxFilter) + } + value={filter} + > + {FILTER_OPTIONS.map((option) => ( + + + {option.label} + {option.value === "reminders" && + dueReminderCount > 0 ? ( + + {dueReminderCount} + + ) : null} + + + ))} + + +
From 7b68dd8602aa11d771ffb29fd5b3e00220be376d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 19 Jun 2026 10:43:53 +0100 Subject: [PATCH 14/31] Move inbox options to panel left --- .../src/features/home/ui/InboxListPane.tsx | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 08852734f..5e6c4cc8b 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -320,58 +320,58 @@ export function InboxListPane({
-
- - - - - -
- - -
- - + + +
+ + +
+ + +
+
+
+ + {channel ? ( + + + setIsChannelManagementOpen(false)} + onOpenChange={setIsChannelManagementOpen} + open={isChannelManagementOpen} + /> + + ) : null} ); } From d2aecb08f1b74ef3b24601ffe2c774d92b0622ad Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 19 Jun 2026 10:52:29 +0100 Subject: [PATCH 16/31] Remove duplicate inbox detail read menu --- desktop/src/features/home/ui/HomeView.tsx | 20 ------- .../src/features/home/ui/InboxDetailPane.tsx | 59 +++++-------------- 2 files changed, 14 insertions(+), 65 deletions(-) diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 916669dd5..09e9717d2 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -322,18 +322,6 @@ export function HomeView({ setIsSendingReply(false); }, [selectedItemId]); - const handleToggleDone = React.useCallback( - (itemId: string) => { - if (effectiveDoneSet.has(itemId)) { - markItemUnread(itemId); - return; - } - - markItemRead(itemId); - }, - [effectiveDoneSet, markItemRead, markItemUnread], - ); - if (isLoading && !feed) { return ; } @@ -496,9 +484,6 @@ export function HomeView({ currentPubkey={currentPubkey} disabledReplyReason={disabledReplyReason} isDeletingMessage={isDeletingMessage} - isDone={ - selectedItem ? effectiveDoneSet.has(selectedItem.id) : false - } isSendingReply={isSendingReply} isSinglePanelView={isSinglePanelDetailView} isThreadContextLoading={threadContext.isLoading} @@ -591,11 +576,6 @@ export function HomeView({ setIsSendingReply(false); } }} - onToggleDone={() => { - if (selectedItem) { - handleToggleDone(selectedItem.id); - } - }} onToggleReaction={ canReact ? async (message, emoji, remove) => { diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index e17219f05..5ac103d39 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -1,12 +1,4 @@ -import { - ArrowLeft, - CheckCheck, - Hash, - Mail, - MailOpen, - MoreHorizontal, - Trash2, -} from "lucide-react"; +import { ArrowLeft, Hash, Mail, MoreHorizontal, Trash2 } from "lucide-react"; import * as React from "react"; import type { @@ -31,7 +23,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { @@ -56,7 +47,6 @@ type InboxDetailPaneProps = { canOpenChannel: boolean; canReply: boolean; disabledReplyReason?: string | null; - isDone: boolean; isDeletingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; @@ -81,7 +71,6 @@ type InboxDetailPaneProps = { emoji: string, remove: boolean, ) => Promise; - onToggleDone: () => void; }; export function InboxDetailPane({ @@ -89,7 +78,6 @@ export function InboxDetailPane({ canOpenChannel, canReply, disabledReplyReason, - isDone, isDeletingMessage = false, isSendingReply = false, isSinglePanelView = false, @@ -105,7 +93,6 @@ export function InboxDetailPane({ onOpenContext, onSendReply, onToggleReaction, - onToggleDone, }: InboxDetailPaneProps) { const detailPaneRef = React.useRef(null); const [replyTargetId, setReplyTargetId] = React.useState(null); @@ -317,13 +304,12 @@ export function InboxDetailPane({ } /> ) : null} - + {canDelete ? ( + + ) : null}
@@ -408,17 +394,11 @@ export function InboxDetailPane({ } function HeaderMoreMenu({ - canDelete, isDeletingMessage, - isDone, onDelete, - onToggleDone, }: { - canDelete: boolean; isDeletingMessage: boolean; - isDone: boolean; onDelete: () => void; - onToggleDone: () => void; }) { const trigger = (
-
+
{isDone ? ( From 460a75f954da823d0bbc4ac9827811b44c9585e2 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 19 Jun 2026 11:00:23 +0100 Subject: [PATCH 18/31] Align inbox message action tray --- desktop/src/features/home/ui/InboxMessageRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 81062f109..d598761b6 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -88,7 +88,7 @@ export function InboxMessageRow({ } > {canReply || canToggleReactions ? ( -
+
Date: Fri, 19 Jun 2026 12:22:20 +0100 Subject: [PATCH 19/31] Address inbox review feedback --- desktop/src/app/AppShell.tsx | 116 +++++++++++++----- desktop/src/app/AppShellContext.tsx | 2 +- desktop/src/app/routes/index.tsx | 4 +- .../features/channels/useUnreadChannels.ts | 51 +++++++- desktop/src/features/home/lib/inbox.test.mjs | 8 ++ desktop/src/features/home/lib/inbox.ts | 2 + desktop/src/features/home/ui/HomeScreen.tsx | 6 +- desktop/src/features/home/ui/HomeView.tsx | 13 +- .../src/features/home/ui/InboxDetailPane.tsx | 16 ++- .../home/useHomeInboxReadState.test.mjs | 67 ++++++++++ .../features/home/useHomeInboxReadState.ts | 30 ++++- desktop/src/features/notifications/hooks.ts | 33 +++-- 12 files changed, 295 insertions(+), 53 deletions(-) create mode 100644 desktop/src/features/home/useHomeInboxReadState.test.mjs diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b19c7fd10..26ac19bb1 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -90,7 +90,11 @@ import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; -import { KIND_APPROVAL_REQUEST, KIND_REMINDER } from "@/shared/constants/kinds"; +import { + KIND_APPROVAL_REQUEST, + KIND_EVENT_REMINDER, + KIND_REMINDER, +} from "@/shared/constants/kinds"; import { ConnectionBanner } from "@/shared/ui/ConnectionBanner"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; @@ -187,34 +191,60 @@ export function AppShell() { } let isCancelled = false; - let dispose: (() => Promise) | null = null; + let disposers: Array<() => Promise> = []; + const since = Math.floor(Date.now() / 1_000); + const handleLiveHomeFeedEvent = () => { + refetchHomeFeedFromLiveSignal(); + }; - void relayClient - .subscribeLive( + void Promise.allSettled([ + relayClient.subscribeLive( { kinds: [...HOME_FEED_ACTION_KINDS], "#p": [pubkey], limit: 50, - since: Math.floor(Date.now() / 1_000), + since, }, - () => { - refetchHomeFeedFromLiveSignal(); + handleLiveHomeFeedEvent, + ), + relayClient.subscribeLive( + { + authors: [pubkey], + kinds: [KIND_EVENT_REMINDER], + limit: 50, + since, }, - ) - .then((nextDispose) => { - if (isCancelled) { - void nextDispose().catch(() => {}); - return; + handleLiveHomeFeedEvent, + ), + ]).then((results) => { + const nextDisposers = results.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + for (const result of results) { + if (result.status === "rejected") { + console.error( + "Failed to subscribe to live home feed actions", + result.reason, + ); } - dispose = nextDispose; - }) - .catch((error) => { - console.error("Failed to subscribe to live home feed actions", error); - }); + } + + if (nextDisposers.length === 0) { + return; + } + + if (isCancelled) { + for (const dispose of nextDisposers) { + void dispose().catch(() => {}); + } + return; + } + disposers = nextDisposers; + }); return () => { isCancelled = true; - if (dispose) { + for (const dispose of disposers) { void dispose().catch(() => {}); } }; @@ -358,6 +388,7 @@ export function AppShell() { highPriorityUnreadChannelIds, unreadChannelNotificationCount, getEffectiveTimestamp: getChannelReadAt, + getOwnTimestamp: getOwnReadAt, readStateVersion, setContextParentResolver, participatedRootIds, @@ -381,8 +412,22 @@ export function AppShell() { }); const getThreadReadAt = React.useCallback( - (rootId: string) => getChannelReadAt(`thread:${rootId}`), - [getChannelReadAt], + (rootId: string, channelId?: string | null) => { + const threadReadAt = getOwnReadAt(`thread:${rootId}`); + if (!channelId) { + return threadReadAt; + } + + const channelReadAt = getChannelReadAt(channelId); + if (threadReadAt === null) { + return channelReadAt; + } + if (channelReadAt === null) { + return threadReadAt; + } + return Math.max(threadReadAt, channelReadAt); + }, + [getChannelReadAt, getOwnReadAt], ); const markThreadRead = React.useCallback( @@ -396,19 +441,24 @@ export function AppShell() { ); const threadActivityFeedItems = React.useMemo( () => - threadActivityItems.map((item) => ({ - 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, - })), - [threadActivityItems], + threadActivityItems + .filter((item) => { + const rootId = getThreadReference(item.tags).rootId; + return !rootId || !mutedRootIds.has(rootId); + }) + .map((item) => ({ + 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, + })), + [mutedRootIds, threadActivityItems], ); // Badge count is computed here (rather than inside useHomeFeedNotifications) diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 44e415d17..35169f6ff 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -21,7 +21,7 @@ type AppShellContextValue = { getChannelReadAt: (channelId: string) => number | null; // Thread read frontier as unix-seconds timestamp, or null when never read. // Uses `thread:` context keys in the same ReadStateManager. - getThreadReadAt: (rootId: string) => number | null; + getThreadReadAt: (rootId: string, channelId?: string | null) => number | null; // Advance the thread read frontier to the given unix-seconds timestamp. markThreadRead: (rootId: string, timestamp: number) => void; // Bump-counter that invalidates whenever the read marker changes. Include diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 1648bf2a1..cb4c46880 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -79,8 +79,8 @@ function HomeRouteComponent() { { - void goChannel(channelId, { messageId }); + onOpenContext={(channelId, messageId, threadRootId) => { + void goChannel(channelId, { messageId, threadRootId }); }} /> ); diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index cc74ec56f..5c0445805 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -281,6 +281,7 @@ export function useUnreadChannels( const { getEffectiveTimestamp, + getOwnTimestamp, isReady: isReadStateReady, markContextRead, drainSyncedAdvances, @@ -381,6 +382,21 @@ export function useUnreadChannels( 0, ); + const getThreadActivityReadAt = React.useCallback( + (channelId: string, rootId: string): number | null => { + const threadReadAt = getOwnTimestamp(`thread:${rootId}`); + const channelReadAt = getEffectiveTimestamp(channelId); + if (threadReadAt === null) { + return channelReadAt; + } + if (channelReadAt === null) { + return threadReadAt; + } + return Math.max(threadReadAt, channelReadAt); + }, + [getEffectiveTimestamp, getOwnTimestamp], + ); + // Reset all in-session state when the identity or relay changes. Unread // tracking depends only on NIP-RS read markers + observed relay events for // this user; nothing here is persisted across restarts. @@ -882,6 +898,7 @@ export function useUnreadChannels( threadActivityBackfillKeyRef.current = backfillKey; let isCancelled = false; + let didFinish = false; const targetIds = channelIdsKey.split(","); const channelById = new Map( channels.map((channel) => [channel.id, channel]), @@ -963,6 +980,10 @@ export function useUnreadChannels( ) { continue; } + const readAt = getThreadActivityReadAt(channelId, ref.rootId); + if (readAt !== null && event.created_at <= readAt) { + continue; + } const replies = candidateRepliesByChannel.get(channelId) ?? []; replies.push(event); @@ -986,6 +1007,10 @@ export function useUnreadChannels( for (const [channelId, events] of candidateRepliesByChannel) { const channelName = channelById.get(channelId)?.name ?? ""; for (const event of events) { + const ref = getThreadReference(event.tags); + if (ref.rootId === null) { + continue; + } if ( !shouldNotifyForEvent(event, normalizedPubkey, { participatedRootIds: participatedRootIdsRef.current, @@ -998,6 +1023,10 @@ export function useUnreadChannels( ) { continue; } + const readAt = getThreadActivityReadAt(channelId, ref.rootId); + if (readAt !== null && event.created_at <= readAt) { + continue; + } threadReplies.push({ id: event.id, @@ -1082,6 +1111,10 @@ export function useUnreadChannels( ) { continue; } + const readAt = getThreadActivityReadAt(channelId, ref.rootId); + if (readAt !== null && event.created_at <= readAt) { + continue; + } const channelName = channelById.get(channelId)?.name ?? ""; threadReplies.push({ @@ -1113,15 +1146,23 @@ export function useUnreadChannels( ) { bumpMembershipVersion(); } - })(); + })().finally(() => { + if (!isCancelled) { + didFinish = true; + } + }); return () => { isCancelled = true; + if (!didFinish && threadActivityBackfillKeyRef.current === backfillKey) { + threadActivityBackfillKeyRef.current = null; + } }; }, [ channelIdsKey, channels, followedRootIdsKey, + getThreadActivityReadAt, liveUpdateOptions.followedRootIds, normalizedPubkey, relayClient, @@ -1322,6 +1363,13 @@ export function useUnreadChannels( } } if (isThreadedReply) { + const rootId = evtRef.rootId ?? evtRef.parentId; + if (rootId) { + const threadReadAt = getThreadActivityReadAt(channelId, rootId); + if (threadReadAt !== null && event.created_at <= threadReadAt) { + continue; + } + } threadReplies.push({ id: event.id, kind: event.kind, @@ -1605,6 +1653,7 @@ export function useUnreadChannels( // ReadStateManager. readStateVersion is the invalidation signal callers // should include in memo deps. getEffectiveTimestamp, + getOwnTimestamp, readStateVersion, setContextParentResolver, participatedRootIds, diff --git a/desktop/src/features/home/lib/inbox.test.mjs b/desktop/src/features/home/lib/inbox.test.mjs index 02c3a9c91..88e007447 100644 --- a/desktop/src/features/home/lib/inbox.test.mjs +++ b/desktop/src/features/home/lib/inbox.test.mjs @@ -108,6 +108,10 @@ test("thread groups are represented by the latest reply rather than the root", ( assert.equal(inboxItem.id, "reply-event"); assert.equal(inboxItem.preview, "New reply in the thread"); + assert.deepEqual( + inboxItem.groupItems.map((groupItem) => groupItem.id), + ["root-event", "reply-event"], + ); assert.deepEqual(getInboxTypeLabel(inboxItem), { text: "Thread in", channelLabel: "buzz-bugs", @@ -143,6 +147,10 @@ test("thread groups use the latest row label even when the root was a mention", }); assert.equal(inboxItem.id, "reply-event"); + assert.deepEqual( + inboxItem.groupItems.map((groupItem) => groupItem.id), + ["root-event", "reply-event"], + ); assert.deepEqual(getInboxTypeLabel(inboxItem), { text: "Thread in", channelLabel: "buzz-bugs", diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 5f4a13028..bc58c4dfe 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -33,6 +33,7 @@ export type InboxItem = { categoryLabel: string; channelLabel: string | null; fullTimestampLabel: string; + groupItems: FeedItem[]; isActionRequired: boolean; latestActivityAt: number; mentionNames: string[]; @@ -440,6 +441,7 @@ export function buildInboxItems({ categoryLabel, channelLabel, fullTimestampLabel: formatInboxFullTimestamp(item.createdAt), + groupItems: group.items, isActionRequired: categories.includes("needs_action"), latestActivityAt: group.latestActivityAt, mentionNames, diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index 07469e13f..ec366af54 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -13,7 +13,11 @@ import { type HomeScreenProps = { availableChannelIds: ReadonlySet; currentPubkey?: string; - onOpenContext: (channelId: string, messageId: string) => void; + onOpenContext: ( + channelId: string, + messageId: string, + threadRootId?: string | null, + ) => void; }; export function HomeScreen({ diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 09e9717d2..e9ccf8949 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -34,6 +34,7 @@ import { formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { @@ -59,7 +60,11 @@ type HomeViewProps = { errorMessage?: string; currentPubkey?: string; availableChannelIds: ReadonlySet; - onOpenContext: (channelId: string, messageId: string) => void; + onOpenContext: ( + channelId: string, + messageId: string, + threadRootId?: string | null, + ) => void; onRefresh: () => void; }; @@ -422,7 +427,11 @@ export function HomeView({ if (!channelId) { return; } - onOpenContext(channelId, item.id); + onOpenContext( + channelId, + item.id, + getThreadReference(item.item.tags).rootId, + ); }} onRemindLater={(item) => { const channelId = item.item.channelId; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 5ac103d39..dafd578c4 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -12,6 +12,7 @@ import { type InboxDisplayMessage, InboxMessageRow, } from "@/features/home/ui/InboxMessageRow"; +import { getThreadReference } from "@/features/messages/lib/threading"; import type { TimelineMessage } from "@/features/messages/types"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; @@ -59,7 +60,11 @@ type InboxDetailPaneProps = { currentPubkey?: string; onBack?: () => void; onDelete: () => void; - onOpenContext?: (channelId: string, messageId: string) => void; + onOpenContext?: ( + channelId: string, + messageId: string, + threadRootId?: string | null, + ) => void; onSendReply: (input: { content: string; mediaTags?: string[][]; @@ -224,6 +229,7 @@ export function InboxDetailPane({ const contextLabel = channelContextName ?? formatInboxTypeLabel(item); const hasChannelContext = Boolean(channelContextName); const contextChannelId = item.item.channelId; + const contextThreadRootId = getThreadReference(item.item.tags).rootId; const handleSelectReplyTarget = (message: InboxDisplayMessage) => { setReplyTargetId((currentReplyTargetId) => @@ -264,7 +270,13 @@ export function InboxDetailPane({ {canOpenChannel && contextChannelId && onOpenContext ? (