diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 28af88229..b26a41519 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -39,6 +39,8 @@ const overrides = new Map([ ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], + ["src/app/AppShell.tsx", 1080], + ["src/features/channels/useUnreadChannels.ts", 1660], ]); await runFileSizeCheck({ diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 548e9a86e..2cb044dfd 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"; @@ -65,6 +66,7 @@ import { isSettingsSection, } from "@/features/settings/ui/SettingsPanels"; import { HuddleBar, HuddleProvider } from "@/features/huddle"; +import { remindersQueryKey } from "@/features/reminders/hooks"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; @@ -77,16 +79,28 @@ 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_EVENT_REMINDER, + 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 }; @@ -161,13 +175,85 @@ 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 disposers: Array<() => Promise> = []; + const since = Math.floor(Date.now() / 1_000); + const handleLiveHomeFeedEvent = () => { + refetchHomeFeedFromLiveSignal(); + }; + const handleLiveReminderEvent = () => { + refetchHomeFeedFromLiveSignal(); + void queryClient.invalidateQueries({ + queryKey: remindersQueryKey(pubkey), + }); + }; + + void Promise.allSettled([ + relayClient.subscribeLive( + { + kinds: [...HOME_FEED_ACTION_KINDS], + "#p": [pubkey], + limit: 50, + since, + }, + handleLiveHomeFeedEvent, + ), + relayClient.subscribeLive( + { + authors: [pubkey], + kinds: [KIND_EVENT_REMINDER], + limit: 50, + since, + }, + handleLiveReminderEvent, + ), + ]).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, + ); + } + } + + if (nextDisposers.length === 0) { + return; + } + + if (isCancelled) { + for (const dispose of nextDisposers) { + void dispose().catch(() => {}); + } + return; + } + disposers = nextDisposers; + }); + + return () => { + isCancelled = true; + for (const dispose of disposers) { + void dispose().catch(() => {}); + } + }; + }, [identityQuery.data?.pubkey, queryClient]); const handleChannelNotification = React.useEffectEvent( (_channelId: string, _event: RelayEvent) => { if (!notificationSettings.settings.desktopEnabled) return; @@ -306,7 +392,9 @@ export function AppShell() { unreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, + unreadChannelNotificationCount, getEffectiveTimestamp: getChannelReadAt, + getOwnTimestamp: getOwnReadAt, readStateVersion, setContextParentResolver, participatedRootIds, @@ -324,14 +412,28 @@ export function AppShell() { notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, onChannelMessage: handleChannelNotification, onDmMessage: handleDmNotification, - onLiveMention: refetchHomeFeedOnLiveMention, + onLiveMention: refetchHomeFeedFromLiveSignal, onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, followedRootIds, }); 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( @@ -343,6 +445,28 @@ export function AppShell() { }, [markChannelRead], ); + const mutedRootIdsKey = [...mutedRootIds].sort().join("\0"); + const threadActivityFeedItems = React.useMemo(() => { + // mutedRootIds is a mutable Set; this key invalidates when contents change. + void mutedRootIdsKey; + return 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, mutedRootIdsKey, threadActivityItems]); // Badge count is computed here (rather than inside useHomeFeedNotifications) // so it can consume the NIP-RS read-state lifted from the single @@ -361,6 +485,9 @@ export function AppShell() { highPriorityUnreadChannelIds, feedProfilesQuery.data?.profiles, mutedChannelIds, + feedItemState.unreadSet, + threadActivityFeedItems, + getThreadReadAt, ); const isNotifiedForThread = React.useCallback( @@ -537,19 +664,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(); @@ -707,7 +828,9 @@ export function AppShell() { unfollowThread: handleUnfollowThread, isFollowingThread, isNotifiedForThread, + isThreadMuted: (rootId) => mutedRootIds.has(rootId), threadActivityItems, + feedItemState, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 3a34c881c..48604c7a4 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; @@ -18,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 @@ -31,7 +34,9 @@ type AppShellContextValue = { unfollowThread: (rootId: string) => void; isFollowingThread: (rootId: string) => boolean; isNotifiedForThread: (rootId: string) => boolean; + isThreadMuted: (rootId: string) => boolean; threadActivityItems: ThreadActivityItem[]; + feedItemState: FeedItemState; }; const AppShellContext = React.createContext({ @@ -49,7 +54,16 @@ const AppShellContext = React.createContext({ unfollowThread: () => {}, isFollowingThread: () => false, isNotifiedForThread: () => false, + isThreadMuted: () => false, threadActivityItems: [], + feedItemState: { + doneSet: EMPTY_SET, + markDone: () => {}, + markUnread: () => {}, + undoDone: () => {}, + undoUnread: () => {}, + unreadSet: EMPTY_SET, + }, }); export function AppShellProvider({ diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index d3b49e85e..cb4c46880 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -79,11 +79,8 @@ function HomeRouteComponent() { { - void goChannel(channelId); - }} - onOpenContext={(channelId, messageId) => { - void goChannel(channelId, { messageId }); + onOpenContext={(channelId, messageId, threadRootId) => { + void goChannel(channelId, { messageId, threadRootId }); }} /> ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 14ae4203e..f63384ff9 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -95,6 +95,7 @@ export function ChannelScreen({ unfollowThread, isFollowingThread, isNotifiedForThread, + isThreadMuted, readStateVersion, } = useAppShell(); const { @@ -144,8 +145,6 @@ export function ChannelScreen({ optimisticOpenThreadHeadId === undefined ? openThreadHeadId : optimisticOpenThreadHeadId; - const isNotifiedForOpenThread = - openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false; const isNotifiedForEffectiveThread = effectiveOpenThreadHeadId != null ? isNotifiedForThread(effectiveOpenThreadHeadId) @@ -189,9 +188,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; @@ -200,10 +199,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 @@ -399,12 +397,11 @@ export function ChannelScreen({ openThreadHeadId, threadReplyTargetId, expandedThreadReplyIds, - isNotifiedForCurrentThread: isNotifiedForOpenThread, getChannelReadAt, getThreadReadAt, markChannelUnread, markThreadRead, - isNotifiedForThread, + isThreadMuted, readStateVersion, }); const editTargetMessage = React.useMemo( diff --git a/desktop/src/features/channels/ui/useChannelUnreadState.ts b/desktop/src/features/channels/ui/useChannelUnreadState.ts index 57bbfbb5f..e1f498f8e 100644 --- a/desktop/src/features/channels/ui/useChannelUnreadState.ts +++ b/desktop/src/features/channels/ui/useChannelUnreadState.ts @@ -30,12 +30,11 @@ type UseChannelUnreadStateOptions = { openThreadHeadId: string | null; threadReplyTargetId: string | null; expandedThreadReplyIds: ReadonlySet; - isNotifiedForCurrentThread: boolean; getChannelReadAt: (channelId: string) => number | null; - getThreadReadAt: (rootId: string) => number | null; + getThreadReadAt: (rootId: string, channelId?: string | null) => number | null; markChannelUnread: (channelId: string) => void; markThreadRead: (rootId: string, timestamp: number) => void; - isNotifiedForThread: (rootId: string) => boolean; + isThreadMuted: (rootId: string) => boolean; readStateVersion: number; }; @@ -58,12 +57,11 @@ export function useChannelUnreadState({ openThreadHeadId, threadReplyTargetId, expandedThreadReplyIds, - isNotifiedForCurrentThread, getChannelReadAt, getThreadReadAt, markChannelUnread, markThreadRead, - isNotifiedForThread, + isThreadMuted, readStateVersion, }: UseChannelUnreadStateOptions) { // Capture the read frontier as it stood the instant this channel was opened, @@ -206,7 +204,7 @@ export function useChannelUnreadState({ ) { threadOpenFrontierRef.current.set( openThreadHeadId, - getThreadReadAt(openThreadHeadId), + getThreadReadAt(openThreadHeadId, activeChannelId), ); } const threadOpenFrontierSeconds = openThreadHeadId @@ -222,29 +220,22 @@ export function useChannelUnreadState({ }, [openThreadHeadId]); // Mark thread read when the panel opens, advancing the frontier to the max // createdAt over the head and its ENTIRE subtree — every reply, including - // ones nested in collapsed branches. Opening a notified thread means engaging - // with it, so the badge must collapse the instant the panel opens (not wait - // for a channel change or for each branch to be expanded). The badge counts - // the whole subtree (computeThreadBadgeCounts), so marking only the visible - // direct replies would leave it lit whenever the unread lives in a nested - // reply — the reported bug. Consuming collapsed branches here is not lossy: - // a NEWER reply re-raises the badge, because the unread comparison is strictly - // `createdAt > frontier` (computeThreadUnreadMarker) and the badge snapshot - // advances toward the live marker (nextThreadBadgeFrontier). - // Only persist read state for threads the user has notification interest in - // (participated, authored, or followed) to avoid bloating the context blob. + // ones nested in collapsed branches. Opening a badge-eligible thread means + // engaging with it, so the badge must collapse the instant the panel opens + // (not wait for a channel change or for each branch to be expanded). The + // badge counts the whole subtree (computeThreadBadgeCounts), so marking only + // the visible direct replies would leave it lit whenever the unread lives in + // a nested reply — the reported bug. Consuming collapsed branches here is not + // lossy: a NEWER reply re-raises the badge, because the unread comparison is + // strictly `createdAt > frontier` (computeThreadUnreadMarker) and the badge + // snapshot advances toward the live marker (nextThreadBadgeFrontier). React.useEffect(() => { if (!openThreadHeadId) return; - if (!isNotifiedForCurrentThread) return; + if (isThreadMuted(openThreadHeadId)) return; const openReadCeiling = getSubtreeMaxCreatedAt(openThreadHeadId); if (openReadCeiling === null) return; markThreadRead(openThreadHeadId, openReadCeiling); - }, [ - openThreadHeadId, - getSubtreeMaxCreatedAt, - markThreadRead, - isNotifiedForCurrentThread, - ]); + }, [openThreadHeadId, getSubtreeMaxCreatedAt, markThreadRead, isThreadMuted]); // Compute the in-thread "New" divider position from the open-time frontier. const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo(() => { if (!openThreadHeadId || threadMessages.length === 0) { @@ -317,8 +308,8 @@ export function useChannelUnreadState({ channelFrontiers, timelineMessages, directRepliesByParentId, - isNotifiedForThread, - getThreadReadAt, + (rootId) => !isThreadMuted(rootId), + (rootId) => getThreadReadAt(rootId, activeChannelId), ); } // Clear the thread badge frontiers on channel leave (same cleanup as @@ -343,7 +334,7 @@ export function useChannelUnreadState({ activeChannelId ? threadBadgeFrontiersRef.current.get(activeChannelId) : undefined, - isNotifiedForThread, + (rootId) => !isThreadMuted(rootId), currentPubkey, ), [ @@ -351,7 +342,7 @@ export function useChannelUnreadState({ currentPubkey, timelineMessages, directRepliesByParentId, - isNotifiedForThread, + isThreadMuted, readStateVersion, ], ); diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index 6f8985a58..406e3c05c 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -9,6 +9,7 @@ import { recordObservedUnreadEvent, } from "./unreadChannelCounts.ts"; import { + addThreadActivityItems, resolveChannelReadMarker, resolveObservedUnreadRootId, } from "./useUnreadChannels.ts"; @@ -270,3 +271,26 @@ test("highPriorityObservedEvents_countOnlyUnreadHighPriorityItems", () => { assert.equal(countUnreadObservedEvents(events, getReadAt), 2); assert.equal(countUnreadHighPriorityObservedEvents(events, getReadAt), 1); }); + +test("addThreadActivityItems keeps newest items when input is newest-first", () => { + const newestFirst = Array.from({ length: 101 }, (_, index) => { + const createdAt = 100 - index; + return { + id: `reply-${createdAt}`, + kind: 9, + pubkey: "author", + content: "reply", + createdAt, + channelId: "channel", + channelName: "general", + tags: [["h", "channel"]], + }; + }); + + const result = addThreadActivityItems([], newestFirst); + + assert.equal(result.didAdd, true); + assert.equal(result.items.length, 100); + assert.equal(result.items[0].id, "reply-1"); + assert.equal(result.items.at(-1).id, "reply-100"); +}); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs new file mode 100644 index 000000000..f6949bcaf --- /dev/null +++ b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldRouteChannelUnreadEvent } from "./useLiveChannelUpdates.ts"; + +test("main-channel messages route to channel unread tracking", () => { + assert.equal(shouldRouteChannelUnreadEvent(undefined, false), true); +}); + +test("non-DM thread replies do not route to channel unread tracking", () => { + assert.equal( + shouldRouteChannelUnreadEvent({ channelType: "stream" }, true), + false, + ); +}); + +test("DM thread replies route to channel unread tracking", () => { + assert.equal( + shouldRouteChannelUnreadEvent({ channelType: "dm" }, true), + true, + ); +}); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 14950e9e7..a645c6c1a 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,24 @@ 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. Non-DM thread replies + * are routed through onThreadReplyNotification instead; DM thread replies + * also fire this callback so the DM unread dot/count stays channel-level. * 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 @@ -63,6 +73,13 @@ const UNREAD_TRIGGER_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); export const EMPTY_SET: ReadonlySet = new Set(); +export function shouldRouteChannelUnreadEvent( + channel: Pick | undefined, + isThreadedReply: boolean, +): boolean { + return !isThreadedReply || channel?.channelType === "dm"; +} + function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { return ( currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey @@ -196,27 +213,45 @@ 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) { + 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 ( + shouldRouteChannelUnreadEvent( + dmChannelMap.get(channelId), + isThreadedReply, + ) + ) { + options.onChannelMessage?.(channelId, event); + if (isThreadedReply) { options.onThreadReplyNotification?.(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 98f5ff841..b9d8eb21e 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { EMPTY_SET, + shouldRouteChannelUnreadEvent, useLiveChannelUpdates, type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; @@ -135,6 +136,31 @@ function writeActivityToStorage( } } +export 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 parseTimestamp(value: string | null | undefined) { if (!value) { return null; @@ -489,20 +515,19 @@ export function useUnreadChannels( channelName, tags: [...event.tags], }; - const existing = threadActivityRef.current; - if (existing.some((e) => e.id === item.id)) return; - const next = [...existing, item]; - const capped = - next.length > MAX_ACTIVITY_ITEMS - ? next.slice(next.length - MAX_ACTIVITY_ITEMS) - : next; - threadActivityRef.current = capped; + const added = addThreadActivityItems(threadActivityRef.current, [item]); + if (!added.didAdd) return; + const didRecordMentionedRoot = recordMentionedRoot(event); + threadActivityRef.current = added.items; if (normalizedPubkey !== null) { - writeActivityToStorage(normalizedPubkey, capped); + writeActivityToStorage(normalizedPubkey, added.items); + } + if (didRecordMentionedRoot) { + bumpMembershipVersion(); } bumpLatestVersion(); }, - [channels, normalizedPubkey], + [channels, normalizedPubkey, recordMentionedRoot], ); const muteThread = React.useCallback( @@ -667,20 +692,24 @@ export function useUnreadChannels( continue; } const evtRef = getThreadReference(event.tags); - if (event.created_at > maxExternal) { - maxExternal = event.created_at; + const isThreadedReply = + evtRef.parentId !== null && !isBroadcastReply(event.tags); + if (shouldRouteChannelUnreadEvent(ch, isThreadedReply)) { + if (event.created_at > maxExternal) { + maxExternal = event.created_at; + } + const isHighPriority = + chType === "dm" || + (normalizedPubkey !== null && + isHighPriorityEventForUser(event, normalizedPubkey)); + unreadEvents.push({ + id: event.id, + createdAt: event.created_at, + rootId: resolveObservedUnreadRootId(event.tags), + highPriority: isHighPriority, + }); } - const isHighPriority = - chType === "dm" || - (normalizedPubkey !== null && - isHighPriorityEventForUser(event, normalizedPubkey)); - unreadEvents.push({ - id: event.id, - createdAt: event.created_at, - rootId: resolveObservedUnreadRootId(event.tags), - highPriority: isHighPriority, - }); - if (evtRef.parentId !== null && !isBroadcastReply(event.tags)) { + if (isThreadedReply) { threadReplies.push({ id: event.id, kind: event.kind, @@ -737,19 +766,14 @@ export function useUnreadChannels( } } if (allThreadReplies.length > 0) { - const existingIds = new Set(threadActivityRef.current.map((e) => e.id)); - const newItems = allThreadReplies.filter( - (item) => !existingIds.has(item.id), + const added = addThreadActivityItems( + threadActivityRef.current, + allThreadReplies, ); - if (newItems.length > 0) { - const merged = [...threadActivityRef.current, ...newItems]; - const capped = - merged.length > MAX_ACTIVITY_ITEMS - ? merged.slice(merged.length - MAX_ACTIVITY_ITEMS) - : merged; - threadActivityRef.current = capped; + if (added.didAdd) { + threadActivityRef.current = added.items; if (normalizedPubkey) { - writeActivityToStorage(normalizedPubkey, capped); + writeActivityToStorage(normalizedPubkey, added.items); } didAdvance = true; } @@ -895,6 +919,9 @@ export function useUnreadChannels( ? prevUnreadCountsRef.current : rawUnread.unreadChannelCounts; prevUnreadCountsRef.current = unreadChannelCounts; + const unreadChannelNotificationCount = [ + ...unreadChannelCounts.values(), + ].reduce((total, count) => total + count, 0); const unreadChannelIdsRef = React.useRef(unreadChannelIds); unreadChannelIdsRef.current = unreadChannelIds; @@ -939,6 +966,7 @@ export function useUnreadChannels( unreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, + unreadChannelNotificationCount, markAllChannelsRead, markChannelRead, markChannelUnread, @@ -947,6 +975,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 new file mode 100644 index 000000000..88e007447 --- /dev/null +++ b/desktop/src/features/home/lib/inbox.test.mjs @@ -0,0 +1,158 @@ +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( + inboxItem.groupItems.map((groupItem) => groupItem.id), + ["root-event", "reply-event"], + ); + 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( + 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 575507fc2..6d1f28bd1 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" @@ -28,6 +33,7 @@ export type InboxItem = { categoryLabel: string; channelLabel: string | null; fullTimestampLabel: string; + groupItems: FeedItem[]; isActionRequired: boolean; latestActivityAt: number; mentionNames: string[]; @@ -37,6 +43,11 @@ export type InboxItem = { timestampLabel: string; }; +export type InboxTypeLabel = { + text: string; + channelLabel: string | null; +}; + export type InboxReply = { authorLabel: string; avatarUrl: string | null; @@ -61,6 +72,8 @@ export type InboxGroup = { items: InboxItem[]; }; +type InboxChannel = Pick; + const listTimeFormatter = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", @@ -161,24 +174,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) { @@ -263,10 +344,12 @@ export function groupInboxItems(items: InboxItem[]): InboxGroup[] { } export function buildInboxItems({ + channels, currentPubkey, feed, profiles, }: { + channels?: InboxChannel[]; currentPubkey?: string; feed?: HomeFeedResponse; profiles?: UserProfileLookup; @@ -293,6 +376,9 @@ export function buildInboxItems({ category: "agent_activity" as const, })), ]; + const channelById = new Map( + (channels ?? []).map((channel) => [channel.id, channel]), + ); const threadGroups = new Map< string, @@ -326,7 +412,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)); @@ -339,17 +425,24 @@ 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, fullTimestampLabel: formatInboxFullTimestamp(item.createdAt), + groupItems: group.items, isActionRequired: categories.includes("needs_action"), latestActivityAt: group.latestActivityAt, mentionNames, 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/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index 2301b21b1..ec366af54 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -13,14 +13,16 @@ import { type HomeScreenProps = { availableChannelIds: ReadonlySet; currentPubkey?: string; - onOpenChannel: (channelId: string) => void; - onOpenContext: (channelId: string, messageId: string) => void; + onOpenContext: ( + channelId: string, + messageId: string, + threadRootId?: string | null, + ) => void; }; export function HomeScreen({ availableChannelIds, currentPubkey, - onOpenChannel, onOpenContext, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); @@ -72,7 +74,6 @@ export function HomeScreen({ } feed={augmentedFeed} isLoading={homeFeedQuery.isLoading} - onOpenChannel={onOpenChannel} onOpenContext={onOpenContext} onRefresh={() => { void homeFeedQuery.refetch(); diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 8d79cc3c5..d9a735be9 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 { @@ -35,12 +34,14 @@ 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 { 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"; @@ -59,8 +60,11 @@ type HomeViewProps = { errorMessage?: string; currentPubkey?: string; availableChannelIds: ReadonlySet; - onOpenChannel: (channelId: string) => void; - onOpenContext: (channelId: string, messageId: string) => void; + onOpenContext: ( + channelId: string, + messageId: string, + threadRootId?: string | null, + ) => void; onRefresh: () => void; }; @@ -70,7 +74,6 @@ export function HomeView({ errorMessage, currentPubkey, availableChannelIds, - onOpenChannel, onOpenContext, onRefresh, }: HomeViewProps) { @@ -79,6 +82,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 — @@ -108,6 +112,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 >({}); @@ -117,13 +122,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 +190,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(() => { @@ -309,18 +328,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 ; } @@ -413,18 +420,46 @@ export function HomeView({ > {showListPane ? ( { + const channelId = item.item.channelId; + if (!channelId) { + return; + } + onOpenContext( + channelId, + item.id, + getThreadReference(item.item.tags).rootId, + ); + }} + 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); }} + onUnreadOnlyChange={setUnreadOnly} reminderPubkey={currentPubkey} selectedId={selectedItemId} showRightDivider={showListPane && showDetailPane} + unreadOnly={unreadOnly} /> ) : null} @@ -464,9 +499,6 @@ export function HomeView({ currentPubkey={currentPubkey} disabledReplyReason={disabledReplyReason} isDeletingMessage={isDeletingMessage} - isDone={ - selectedItem ? effectiveDoneSet.has(selectedItem.id) : false - } isSendingReply={isSendingReply} isSinglePanelView={isSinglePanelDetailView} isThreadContextLoading={threadContext.isLoading} @@ -497,7 +529,6 @@ export function HomeView({ setIsDeletingMessage(false); }); }} - onOpenChannel={onOpenChannel} onOpenContext={onOpenContext} onSendReply={async ({ content, @@ -561,11 +592,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 5c7faeb37..dd45d879f 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 { @@ -20,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"; @@ -31,7 +24,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { @@ -41,12 +33,21 @@ import { TooltipTrigger, } from "@/shared/ui/tooltip"; +const ChannelManagementSheet = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelManagementSheet"); + return { default: module.ChannelManagementSheet }; +}); + +const MembersSidebar = React.lazy(async () => { + const module = await import("@/features/channels/ui/MembersSidebar"); + return { default: module.MembersSidebar }; +}); + type InboxDetailPaneProps = { canDelete: boolean; canOpenChannel: boolean; canReply: boolean; disabledReplyReason?: string | null; - isDone: boolean; isDeletingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; @@ -59,8 +60,11 @@ type InboxDetailPaneProps = { currentPubkey?: string; onBack?: () => void; onDelete: () => void; - onOpenChannel: (channelId: string) => void; - onOpenContext?: (channelId: string, messageId: string) => void; + onOpenContext?: ( + channelId: string, + messageId: string, + threadRootId?: string | null, + ) => void; onSendReply: (input: { content: string; mediaTags?: string[][]; @@ -72,7 +76,6 @@ type InboxDetailPaneProps = { emoji: string, remove: boolean, ) => Promise; - onToggleDone: () => void; }; export function InboxDetailPane({ @@ -80,7 +83,6 @@ export function InboxDetailPane({ canOpenChannel, canReply, disabledReplyReason, - isDone, isDeletingMessage = false, isSendingReply = false, isSinglePanelView = false, @@ -93,17 +95,19 @@ export function InboxDetailPane({ currentPubkey, onBack, onDelete, - onOpenChannel, onOpenContext, onSendReply, onToggleReaction, - onToggleDone, }: InboxDetailPaneProps) { const detailPaneRef = React.useRef(null); const [replyTargetId, setReplyTargetId] = React.useState(null); const [isFocusHighlightVisible, setIsFocusHighlightVisible] = React.useState(true); + const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); + const [isChannelManagementOpen, setIsChannelManagementOpen] = + React.useState(false); const selectedItemId = item?.id ?? null; + const selectedChannelId = item?.item.channelId ?? null; const selectedMessageScrollKey = React.useMemo(() => { if (!selectedItemId) { return null; @@ -130,6 +134,12 @@ export function InboxDetailPane({ setReplyTargetId(null); }, [selectedItemId]); + React.useEffect(() => { + void selectedChannelId; + setIsMembersSidebarOpen(false); + setIsChannelManagementOpen(false); + }, [selectedChannelId]); + React.useEffect(() => { void selectedItemId; setIsFocusHighlightVisible(true); @@ -219,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) => @@ -259,7 +270,13 @@ export function InboxDetailPane({ {canOpenChannel && contextChannelId && onOpenContext ? ( -
- +
+
+ {isDone ? ( + onMarkUnread(item.id)} + > + + + ) : ( + onMarkRead(item.id)} + > + + )} - > - {typeLabel} - + onOpenDirect(item)} + > + + + onRemindLater(item)} + > + + +
- + + ); + + return ( + + {row} + + {isDone ? ( + onMarkUnread(item.id)}> + + Mark unread + + ) : ( + onMarkRead(item.id)}> + + Mark as read + + )} + + { + if (hasChannelTarget) { + onOpenDirect(item); + } + }} + > + + {hasChannelTarget ? "Open in channel" : "No channel link"} + + { + if (hasChannelTarget) { + onRemindLater(item); + } + }} + > + + {hasActiveReminder ? "Reminder set" : "Remind me later"} + + + ); }; @@ -151,59 +319,110 @@ 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 +446,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} @@ -248,3 +469,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} + + ); +} diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 1dc82b379..4588e27c4 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -94,7 +94,7 @@ export function InboxMessageRow({ } > {canReply || canToggleReactions ? ( -
+
(() => readStoredIds(key), ); + const [unreadIds, setUnreadIds] = React.useState(() => + readStoredIds(unreadStorageKey(normalizedPubkey)), + ); + const [loadedPubkey, setLoadedPubkey] = React.useState(normalizedPubkey); React.useEffect(() => { setDoneIds(readStoredIds(doneStorageKey(normalizedPubkey))); + setUnreadIds(readStoredIds(unreadStorageKey(normalizedPubkey))); + setLoadedPubkey(normalizedPubkey); }, [normalizedPubkey]); React.useEffect(() => { + if (loadedPubkey !== normalizedPubkey) return; writeStoredIds(doneStorageKey(normalizedPubkey), doneIds); - }, [normalizedPubkey, doneIds]); + }, [loadedPubkey, normalizedPubkey, doneIds]); + + React.useEffect(() => { + if (loadedPubkey !== normalizedPubkey) return; + writeStoredIds(unreadStorageKey(normalizedPubkey), unreadIds); + }, [loadedPubkey, 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.test.mjs b/desktop/src/features/home/useHomeInboxReadState.test.mjs new file mode 100644 index 000000000..426188c0a --- /dev/null +++ b/desktop/src/features/home/useHomeInboxReadState.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getGroupedChannelReadTimestamp, + getGroupedInboxItemIds, + hasGroupedUnreadOverride, +} from "./useHomeInboxReadState.ts"; + +const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +function feedItem(overrides) { + return { + id: overrides.id, + kind: 9, + pubkey: "author", + content: "hello", + createdAt: overrides.createdAt, + channelId: overrides.channelId ?? CHANNEL_ID, + channelName: "buzz-bugs", + tags: overrides.tags ?? [["h", CHANNEL_ID]], + category: overrides.category ?? "activity", + }; +} + +function inboxItem(groupItems, item = groupItems.at(-1)) { + return { + id: item.id, + item, + groupItems, + }; +} + +test("grouped channel read timestamp uses the root row, not the latest thread reply", () => { + const rootItem = feedItem({ + id: "root-event", + category: "mention", + createdAt: 100, + }); + const replyItem = feedItem({ + id: "reply-event", + createdAt: 200, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }); + + assert.deepEqual( + getGroupedChannelReadTimestamp(inboxItem([rootItem, replyItem])), + { + channelId: CHANNEL_ID, + timestamp: 100, + }, + ); +}); + +test("grouped channel read timestamp ignores thread-only groups", () => { + const replyItem = feedItem({ + id: "reply-event", + createdAt: 200, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }); + + assert.equal(getGroupedChannelReadTimestamp(inboxItem([replyItem])), null); +}); + +test("grouped inbox item ids include every item represented by the row", () => { + const rootItem = feedItem({ + id: "root-event", + category: "mention", + createdAt: 100, + }); + const replyItem = feedItem({ + id: "reply-event", + createdAt: 200, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }); + + assert.deepEqual(getGroupedInboxItemIds(inboxItem([rootItem, replyItem])), [ + "reply-event", + "root-event", + ]); +}); + +test("grouped unread override matches any item represented by the row", () => { + const rootItem = feedItem({ + id: "root-event", + category: "mention", + createdAt: 100, + }); + const replyItem = feedItem({ + id: "reply-event", + createdAt: 200, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "parent-event", "", "reply"], + ], + }); + + assert.equal( + hasGroupedUnreadOverride( + inboxItem([rootItem, replyItem]), + new Set(["root-event"]), + ), + true, + ); + assert.equal( + hasGroupedUnreadOverride( + inboxItem([rootItem, replyItem]), + new Set(["other-event"]), + ), + false, + ); +}); diff --git a/desktop/src/features/home/useHomeInboxReadState.ts b/desktop/src/features/home/useHomeInboxReadState.ts index 75134abed..b37cc3138 100644 --- a/desktop/src/features/home/useHomeInboxReadState.ts +++ b/desktop/src/features/home/useHomeInboxReadState.ts @@ -1,49 +1,106 @@ 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, channelId?: string | null) => 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; +} + +export function getGroupedChannelReadTimestamp( + item: InboxItem, +): { channelId: string; timestamp: number } | null { + const channelId = item.item.channelId ?? null; + if (!channelId) { + return null; + } + + let timestamp: number | null = null; + for (const groupItem of item.groupItems) { + if (groupItem.channelId !== channelId || isThreadReply(groupItem.tags)) { + continue; + } + timestamp = Math.max(timestamp ?? 0, groupItem.createdAt); + } + + return timestamp === null ? null : { channelId, timestamp }; +} + +export function getGroupedInboxItemIds(item: InboxItem): string[] { + return [ + ...new Set([item.id, item.item.id, ...item.groupItems.map((i) => i.id)]), + ]; +} + +export function hasGroupedUnreadOverride( + item: InboxItem, + localUnreadSet: ReadonlySet, +): boolean { + return getGroupedInboxItemIds(item).some((id) => localUnreadSet.has(id)); +} + /** * 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 +111,20 @@ export function useHomeInboxReadState({ const effectiveDoneSet = React.useMemo>(() => { const result = new Set(); for (const item of items) { + if (hasGroupedUnreadOverride(item, localUnreadSet)) { + continue; + } + const channelId = item.item.channelId; + const threadRootId = getInboxThreadRootId(item); + if (threadRootId) { + const readAt = getThreadReadAt(threadRootId, channelId); + 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 +137,35 @@ export function useHomeInboxReadState({ } } return result; - }, [getChannelReadAt, items, localDoneSet, readStateVersion]); + }, [ + getChannelReadAt, + getThreadReadAt, + items, + localDoneSet, + localUnreadSet, + readStateVersion, + ]); const markItemRead = React.useCallback( (itemId: string) => { const item = itemById.get(itemId); + const localUnreadIds = item ? getGroupedInboxItemIds(item) : [itemId]; + for (const id of localUnreadIds) { + undoUnreadLocal(id); + } + const threadRootId = item ? getInboxThreadRootId(item) : null; + if (item && threadRootId) { + markThreadRead(threadRootId, item.latestActivityAt); + const groupedChannelRead = getGroupedChannelReadTimestamp(item); + if (groupedChannelRead) { + markChannelRead( + groupedChannelRead.channelId, + new Date(groupedChannelRead.timestamp * 1_000).toISOString(), + ); + } + return; + } + const channelId = item?.item.channelId ?? null; if (item && channelId) { markChannelRead( @@ -82,20 +176,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.test.mjs b/desktop/src/features/notifications/hooks.test.mjs new file mode 100644 index 000000000..5440cae6d --- /dev/null +++ b/desktop/src/features/notifications/hooks.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldCountTowardHomeBadgeSubtotal } from "./lib/homeBadge.ts"; + +test("home badge subtotal excludes high-priority channel items regardless of thread status", () => { + const highPriorityChannelIds = new Set(["dm-channel"]); + + assert.equal( + shouldCountTowardHomeBadgeSubtotal( + { channelId: "dm-channel" }, + highPriorityChannelIds, + ), + false, + ); + assert.equal( + shouldCountTowardHomeBadgeSubtotal( + { channelId: "main-channel" }, + highPriorityChannelIds, + ), + true, + ); + assert.equal( + shouldCountTowardHomeBadgeSubtotal( + { channelId: null }, + highPriorityChannelIds, + ), + true, + ); +}); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 9f7d37b78..f6b1fdddc 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, @@ -24,6 +28,7 @@ import { useFeedDesktopNotifications, writeStoredSeenFeedIds, } from "./use-feed-desktop-notifications"; +import { shouldCountTowardHomeBadgeSubtotal } from "./lib/homeBadge"; export type { DesktopNotificationPermissionState } from "./lib/desktop"; @@ -31,6 +36,20 @@ 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 function dedupeFeedItemsById(items: readonly FeedItem[]): FeedItem[] { + const seen = new Set(); + const result: FeedItem[] = []; + for (const item of items) { + if (seen.has(item.id)) { + continue; + } + seen.add(item.id); + result.push(item); + } + return result; +} export type NotificationSettings = { desktopEnabled: boolean; @@ -359,6 +378,12 @@ export function useHomeFeedNotificationState( highPriorityChannelIds: ReadonlySet, profiles?: UserProfileLookup, mutedChannelIds?: ReadonlySet, + localUnreadFeedIds: ReadonlySet = EMPTY_FEED_ID_SET, + extraInboxItems: readonly FeedItem[] = [], + getThreadReadAt: ( + rootId: string, + channelId?: string | null, + ) => number | null = () => null, ) { useFeedDesktopNotifications( feed, @@ -372,10 +397,12 @@ export function useHomeFeedNotificationState( const [seenFeedIds, setSeenFeedIds] = React.useState(() => readStoredSeenFeedIds(normalizedPubkey), ); - const currentFeedItems = React.useMemo( - () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), - [feed], - ); + const currentFeedItems = React.useMemo(() => { + const items = feed + ? [...feed.feed.mentions, ...feed.feed.needsAction, ...extraInboxItems] + : [...extraInboxItems]; + return dedupeFeedItemsById(items); + }, [extraInboxItems, feed]); const currentFeedIds = React.useMemo( () => currentFeedItems.map((item) => item.id), [currentFeedItems], @@ -405,7 +432,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 +444,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 +455,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, item.channelId); + isUnread = + readAt !== null + ? item.createdAt > readAt + : !seenFeedIdSet.has(item.id); + } else if (item.channelId) { const readAt = getChannelReadAt(item.channelId); isUnread = readAt !== null @@ -436,7 +478,7 @@ export function useHomeFeedNotificationState( } if (!isUnread) continue; total++; - if (!(item.channelId && highPriorityChannelIds.has(item.channelId))) { + if (shouldCountTowardHomeBadgeSubtotal(item, highPriorityChannelIds)) { excludingHighPriority++; } } @@ -447,8 +489,10 @@ export function useHomeFeedNotificationState( }, [ currentFeedItems, getChannelReadAt, + getThreadReadAt, highPriorityChannelIds, isHomeActive, + localUnreadFeedIds, mutedChannelIds, readStateVersion, seenFeedIds, diff --git a/desktop/src/features/notifications/lib/homeBadge.ts b/desktop/src/features/notifications/lib/homeBadge.ts new file mode 100644 index 000000000..a8b8c783f --- /dev/null +++ b/desktop/src/features/notifications/lib/homeBadge.ts @@ -0,0 +1,8 @@ +import type { FeedItem } from "@/shared/api/types"; + +export function shouldCountTowardHomeBadgeSubtotal( + item: Pick, + highPriorityChannelIds: ReadonlySet, +): boolean { + return item.channelId === null || !highPriorityChannelIds.has(item.channelId); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index ff136a12e..ba87c5ac8 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -3,8 +3,8 @@ import { Activity, Bot, FolderGit2, - Home, - Plus, + Inbox, + MessageCirclePlus, Zap, } from "lucide-react"; import * as React from "react"; @@ -540,11 +540,11 @@ export function AppSidebar({ - - Home + + Inbox {homeBadgeCount > 0 ? ( - +
} diff --git a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx index f9696cf32..d5ab67cf8 100644 --- a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx +++ b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx @@ -80,6 +80,13 @@ export function CreateChannelDialog({ // Small delay to let dialog animation start before focusing const timerId = globalThis.setTimeout(() => { + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLElement && + activeElement.closest("#create-channel-form") + ) { + return; + } nameInputRef.current?.focus(); }, 50); return () => globalThis.clearTimeout(timerId); diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 379f3b530..06b8a8b2e 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 ba1349b05..5efaba549 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -924,6 +924,45 @@ 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; + overflow: hidden; + padding-bottom: 0.25rem; +} + +.message-markdown.inbox-preview-markdown > :first-child { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.message-markdown.inbox-preview-markdown > :not(:first-child) { + display: none; +} + +.message-markdown.inbox-preview-markdown br { + display: none; +} + +.message-markdown.inbox-preview-markdown .mention-chip, +.message-markdown.inbox-preview-markdown .inline-code-chip, +.message-markdown.inbox-preview-markdown :not(pre) > code { + 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; diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index be5954250..94e8e3049 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -60,15 +60,29 @@ async function waitForBadgeState( ); } +async function getSettledBadgeState(page: import("@playwright/test").Page) { + // The mock bridge seeds a couple of unread items during app startup. Let + // those settle before asserting deltas from newly emitted messages. + await page.waitForTimeout(2000); + return getBadgeState(page); +} + +function withAdditionalBadgeCount(baseline: { count: number }, count: number) { + return { state: "count", count: baseline.count + count }; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); -test("dot badge for regular message in inactive channel", async ({ page }) => { +test("numeric badge increments for regular message in inactive channel", async ({ + page, +}) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); + const baselineBadge = await getSettledBadgeState(page); await page.evaluate( ({ pubkey }) => { @@ -83,14 +97,17 @@ test("dot badge for regular message in inactive channel", async ({ page }) => { ); await expect(page.getByTestId("channel-unread-random")).toBeVisible(); - await waitForBadgeState(page, { state: "dot" }); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); -test("numeric badge for @mention in inactive channel", async ({ page }) => { +test("numeric badge increments for @mention in inactive channel", async ({ + page, +}) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); + const baselineBadge = await getSettledBadgeState(page); await page.evaluate( ({ pubkey, mentionPubkey }) => { @@ -109,14 +126,15 @@ test("numeric badge for @mention in inactive channel", async ({ page }) => { ); await expect(page.getByTestId("channel-unread-random")).toBeVisible(); - await waitForBadgeState(page, { state: "count", count: 1 }); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); -test("numeric badge for DM message", async ({ page }) => { +test("numeric badge increments for DM message", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "alice-tyler"); + const baselineBadge = await getSettledBadgeState(page); await page.evaluate((pubkey) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ @@ -127,16 +145,17 @@ test("numeric badge for DM message", async ({ page }) => { }, TEST_IDENTITIES.alice.pubkey); await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible(); - await waitForBadgeState(page, { state: "count", count: 1 }); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); -test("numeric badge for broadcast reply in inactive channel", async ({ +test("numeric badge increments for broadcast reply in inactive channel", async ({ page, }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); + const baselineBadge = await getSettledBadgeState(page); await page.evaluate( ({ pubkey }) => { @@ -155,7 +174,7 @@ test("numeric badge for broadcast reply in inactive channel", async ({ ); await expect(page.getByTestId("channel-unread-random")).toBeVisible(); - await waitForBadgeState(page, { state: "count", count: 1 }); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); test("mark-as-read via context menu clears channel unread indicator", async ({ @@ -189,24 +208,27 @@ test("mark-as-read via context menu clears channel unread indicator", async ({ await page.getByText("Mark as read").click(); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); - await waitForBadgeState(page, { state: baselineBadge.state }); + await waitForBadgeState(page, baselineBadge); }); -test("mark-as-unread via context menu shows dot badge", async ({ page }) => { +test("mark-as-unread via context menu increments numeric badge", async ({ + page, +}) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + const baselineBadge = await getSettledBadgeState(page); await page.getByTestId("channel-random").click({ button: "right" }); await page.getByText("Mark unread").click(); await expect(page.getByTestId("channel-unread-random")).toBeVisible(); - await waitForBadgeState(page, { state: "dot" }); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); -test("remote read-state rollback is ignored while local mark-unread still shows dot", async ({ +test("remote read-state rollback is ignored while local mark-unread still increments badge", async ({ page, }) => { await page.goto("/"); @@ -215,6 +237,7 @@ test("remote read-state rollback is ignored while local mark-unread still shows // Baseline: random has no unread dot await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + const baselineBadge = await getSettledBadgeState(page); // Wait for ReadStateManager's live subscription (kind:30078) to be // established before injecting events. @@ -307,7 +330,7 @@ test("remote read-state rollback is ignored while local mark-unread still shows await page.getByTestId("channel-random").click({ button: "right" }); await page.getByText("Mark unread").click(); await expect(page.getByTestId("channel-unread-random")).toBeVisible(); - await waitForBadgeState(page, { state: "dot" }); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); // Step 3: remote advance clears the local forced-unread dot. await page.evaluate( diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index 774e5c033..736362d99 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -290,12 +290,15 @@ test("live mentions refetch the home feed without waiting for polling", async ({ }, ]); - // The home feed should have been refetched live (the original purpose + // The inbox feed should have been refetched live (the original purpose // of this test). The home badge stays at 0 while the user is actively // reading #general — reading in-channel advances the NIP-RS marker past // the new mention — so the assertion that the refetch happened is the // inbox-list content, not the badge. - await targetPage.getByRole("button", { name: "Home" }).click(); + await targetPage + .getByTestId("app-sidebar") + .getByRole("button", { name: "Inbox" }) + .click(); await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toContainText( message, @@ -352,7 +355,10 @@ test("live forum mentions refetch the home feed without waiting for polling", as }, ]); - await targetPage.getByRole("button", { name: "Home" }).click(); + await targetPage + .getByTestId("app-sidebar") + .getByRole("button", { name: "Inbox" }) + .click(); await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toContainText( diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 4a30dc65c..64b216cfe 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -692,7 +692,10 @@ test("renders settings in the app shell with a back button", async ({ }) => { await page.goto("/"); - await expect(page.getByRole("button", { name: "Home" })).toBeVisible(); + const inboxNavButton = page + .getByTestId("app-sidebar") + .getByRole("button", { name: "Inbox" }); + await expect(inboxNavButton).toBeVisible(); await openSettings(page); await expect(page.getByTestId("settings-sidebar")).toBeVisible(); @@ -721,14 +724,14 @@ test("renders settings in the app shell with a back button", async ({ name: "Appearance", }), ).toBeVisible(); - await expect(page.getByRole("button", { name: "Home" })).toHaveCount(0); + await expect(inboxNavButton).toHaveCount(0); await page.getByTestId("settings-back-to-app").click(); await expectHomeView(page); - await expect(page.getByRole("button", { name: "Home" })).toBeVisible(); + await expect(inboxNavButton).toBeVisible(); }); -test("notification settings drive the Home badge and desktop alerts", async ({ +test("notification settings drive the Inbox badge and desktop alerts", async ({ page, }) => { async function getAppBadgeCount() { @@ -857,7 +860,10 @@ test("notification settings drive the Home badge and desktop alerts", async ({ await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); await expect.poll(getAppBadgeCount).toBe(baseline + 1); - await page.getByRole("button", { name: "Home" }).click(); + await page + .getByTestId("app-sidebar") + .getByRole("button", { name: "Inbox" }) + .click(); await expectHomeView(page); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); await expect.poll(getAppBadgeCount).toBe(baseline); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index c0d60d3c2..7e47b76ae 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -74,7 +74,7 @@ async function selectHomeInboxFilter( await page .getByTestId("home-inbox") .getByRole("button", { - name: /^(All|Mentions|Needs Action|Activity|Agents)$/, + name: /^Filter inbox:/, }) .click(); await page.getByRole("menuitemradio", { name: label }).click(); @@ -145,7 +145,7 @@ test("create agent supports parallelism and system prompt overrides", async ({ await expect(inlineLog).toContainText("system prompt override configured"); }); -test("opens a mocked channel from the home feed", async ({ page }) => { +test("opens a mocked channel from the inbox feed", async ({ page }) => { const inboxList = page.getByTestId("home-inbox-list"); await page.goto("/"); @@ -153,19 +153,17 @@ test("opens a mocked channel from the home feed", async ({ page }) => { await expectHomeView(page); await expect(inboxList).toContainText("Please review the release checklist."); - await inboxList - .getByText("Please review the release checklist.") - .first() - .click(); - await page.getByRole("button", { name: "Open channel" }).click(); + const releaseRow = page.getByTestId("home-inbox-item-mock-feed-mention"); + await releaseRow.hover(); + await releaseRow.getByRole("button", { name: "Open in channel" }).click(); await expect(page).toHaveURL( - /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50$/, + /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50\?messageId=mock-feed-mention$/, ); await expect(page.getByTestId("chat-title")).toHaveText("general"); }); -test("home feed shows channel and agent activity sections", async ({ +test("inbox feed shows channel and agent activity sections", async ({ page, }) => { const inboxList = page.getByTestId("home-inbox-list"); @@ -187,7 +185,7 @@ test("home feed shows channel and agent activity sections", async ({ ); }); -test("opens a mocked forum activity item from the home feed", async ({ +test("opens a mocked forum activity item from the inbox feed", async ({ page, }) => { await page.goto("/"); @@ -205,7 +203,7 @@ test("opens a mocked forum activity item from the home feed", async ({ ); }); -test("home feed renders resolved author labels", async ({ page }) => { +test("inbox feed renders resolved author labels", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("home-inbox-list")).toContainText("alice"); diff --git a/desktop/tests/e2e/thread-unread-screenshots.spec.ts b/desktop/tests/e2e/thread-unread-screenshots.spec.ts index bc6fbf259..7f03ddf0e 100644 --- a/desktop/tests/e2e/thread-unread-screenshots.spec.ts +++ b/desktop/tests/e2e/thread-unread-screenshots.spec.ts @@ -218,7 +218,7 @@ test.describe("thread unread indicator screenshots", () => { }); }); - test("03-thread-no-badge-casual-browse", async ({ page }) => { + test("03-thread-badge-casual-browse", async ({ page }) => { await installMockBridge(page); await page.goto("/"); @@ -250,14 +250,26 @@ test.describe("thread unread indicator screenshots", () => { // Wait for thread summary to render await page.waitForTimeout(500); - // The thread summary should NOT show an unread badge — tyler has no - // notification interest in alice's thread (not participated/authored/followed) - const badges = page.getByTestId("thread-unread-badge"); - await expect(badges).toHaveCount(0); + // The thread summary still shows local unread reply state for the visible + // thread, even though this casual thread should not create a channel-nav + // unread dot or notification interest. + const badges = page + .locator(`[data-thread-head-id="${rootEvent.id}"]`) + .getByTestId("thread-unread-badge"); + await expect(badges).toHaveCount(1); + await expect(badges).toContainText("2"); await page.screenshot({ - path: `${SHOTS}/03-thread-no-badge-casual-browse.png`, + path: `${SHOTS}/03-thread-badge-casual-browse.png`, }); + + // Opening a casual, unmuted thread should clear its local badge too. The + // badge render gate and read-on-open gate must stay aligned. + await page.locator(`[data-thread-head-id="${rootEvent.id}"]`).click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await page.getByTestId("message-thread-close").click(); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + await expect(badges).toHaveCount(0); }); test("04-thread-deep-nested-unread", async ({ page }) => { @@ -685,13 +697,12 @@ test.describe("thread unread indicator screenshots", () => { }); }); - // Pins the Fix-A sidebar consequence Will approved on the record: a channel - // whose ONLY unread is an unopened thread reply KEEPS its sidebar dot after - // the channel is viewed. Channel-open advances the marker over top-level - // messages only and does NOT clear observed-latest, so the reply still counts - // as unread for the sidebar. A future change that re-folds replies into the - // channel-view marker would drop the dot on view and fail here. - test("11-sidebar-dot-persists-after-channel-view", async ({ page }) => { + // Thread-only replies now route through Inbox instead of lighting the + // channel's sidebar dot. Viewing the channel should still leave the channel + // dot clear when the only new item is an unopened thread reply. + test("11-thread-reply-does-not-light-sidebar-dot-after-channel-view", async ({ + page, + }) => { await installMockBridge(page); await page.goto("/"); @@ -723,39 +734,35 @@ test.describe("thread unread indicator screenshots", () => { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - // The crux: leave general. Its sidebar dot must remain — viewing the - // channel did NOT absorb the unopened thread reply (Fix A). + // The crux: leave general. Its sidebar dot must stay clear because + // thread-only reply activity belongs in Inbox, not the channel nav. await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); - await expect(page.getByTestId("channel-unread-general")).toBeVisible(); + await expect(page.getByTestId("channel-unread-general")).toHaveCount(0); await page.screenshot({ - path: `${SHOTS}/11-sidebar-dot-persists.png`, + path: `${SHOTS}/11-thread-reply-no-sidebar-dot.png`, }); }); // Regression guard for the all-replies window: when the loaded window holds // ONLY thread replies (the top-level root has scrolled past the history - // limit), `latestActiveMessage` is null and `activeReadAt` must NOT fall back - // to the channel's `lastMessageAt` — that value is reply-inclusive (a reply's - // own timestamp), so advancing the channel marker to it silently absorbs the - // unread reply and clears the dot, defeating Fix A. The fix nulls the - // fallback so the marker advance is suppressed until a real top-level - // position is known; this pins the dot's survival in that window. + // limit), thread-only activity should still stay out of channel unread dots. // // The `all-replies` fixture carries a far-future `lastMessageAt` (standing in // for the backend's reply-inclusive MAX) with no top-level message in its - // window — so the buggy fallback would advance the marker past the reply. - test("12-sidebar-dot-survives-all-replies-window", async ({ page }) => { + // window. + test("12-thread-reply-does-not-light-all-replies-sidebar-dot", async ({ + page, + }) => { await installMockBridge(page); await page.goto("/"); // Emit ONE reply whose parent root is NOT in the window (orphan parent id), // so the loaded window is all-replies: no top-level message exists for // `latestActiveMessage` to find. The reply mentions the current user so it - // clears the notify gate and lights the sidebar dot — the observable this - // test asserts on. (Any notify trigger works; a mention is the simplest. - // The bug is independent of why the reply is notified.) + // clears the notify gate and creates Inbox activity without lighting the + // channel sidebar dot. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "all-replies"); @@ -765,22 +772,20 @@ test.describe("thread unread indicator screenshots", () => { mentionPubkeys: [SELF_PUBKEY], createdAt: unreadTimestamp(), }); - await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible(); + await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0); - // View all-replies while the reply is unread. The all-replies window forces - // the `activeReadAt` fallback; the bug would advance the channel marker to - // the far-future `lastMessageAt` and clear the dot. + // View all-replies while the reply is unread. await page.getByTestId("channel-all-replies").click(); await expect(page.getByTestId("chat-title")).toHaveText("all-replies"); - // The crux: leave the channel. Its sidebar dot must remain — the reply is - // still unread, and viewing the all-replies window must not absorb it. + // The crux: leave the channel. Its sidebar dot should remain clear because + // thread-only reply activity belongs in Inbox. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible(); + await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0); await page.screenshot({ - path: `${SHOTS}/12-sidebar-dot-all-replies.png`, + path: `${SHOTS}/12-thread-reply-no-all-replies-sidebar-dot.png`, }); });