From 2bd4c24b71335e7ce272ec6de6491f7f37f4b20d Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Mon, 27 Jul 2026 16:45:34 -0400 Subject: [PATCH] Inbox refactor (#2045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The Inbox mixed overlapping feed categories with personal work queues, so **All** was not actually comprehensive and several filters did not make it clear why an item appeared. Threads and DMs could produce one row per event instead of one row per conversation, drafts were hidden until selected, and reminders appeared through multiple competing presentations. This refactor makes the **Inbox** a focused, conversation-oriented place to catch up on work relevant to you. It is intentionally not a mirror of every unread event in every channel. ## What changed - Keep the destination named **Inbox** and use the standard Lucide bell icon. - Refocus **All** on DMs, mentions, thread replies, needs-action items, replies from agents the user owns or controls, due reminders, and active drafts. - Exclude generic top-level channel traffic and updates from agents the user does not own or control. - Group each thread or DM into one row, sorted by latest activity. - Resume an unread conversation at its oldest unread message while opening the full thread or DM in the detail pane. - Reuse the existing **New** divider at the unread boundary. - Make the detail title a direct link to the canonical conversation. - Give Reminders and Drafts the same list/detail interaction and location metadata as conversation rows. - Separate Reminders and Drafts from message filters with a subtle divider, without adding another labeled section. - Put reminder and draft counts beside their corresponding filter labels instead of on the generic filter button. - Preserve the selected conversation when switching filters if it remains valid; otherwise select a valid replacement without flashing stale detail. - Use filter-specific empty states and rename the options toggle to **Show unread only**. - Ship the focused behavior directly. The earlier experiment gate, Custom view, and default-view controls have been removed from this PR to keep the first pass focused. ## Filter model | Filter | What appears | | --- | --- | | **All** | One row per personally relevant conversation, plus due reminders and active drafts. Includes DMs, mentions, thread replies, explicit needs-action items, and replies from agents the current user owns or controls. Excludes generic top-level channel traffic, other agents' updates, and reminders that are not due yet. | | **Mentions** | Conversations containing a direct mention. Each conversation appears once and opens with full context. | | **Threads** | Conventional threaded replies, grouped to one row per thread. Broadcast replies are not treated as conventional thread replies. | | **Needs action** | Feed items explicitly classified as requiring action. | | **Agents** | Conversations whose representative response was authored by an agent the current user owns or controls, including top-level DM responses. If a human replies afterward, the conversation leaves this filter until an owned agent responds again. | | **Reminders** | All pending reminders, including upcoming reminders that stay out of **All** until they are due. | | **Drafts** | Active drafts, ordered by their last real edit time. | ## Grouping, ordering, and state - A thread or DM creates one Inbox row rather than one row per event. - An unread conversation resumes at its oldest unread message so intervening context is not skipped. - Conversation rows still sort by their latest activity. - The detail pane opens the full available conversation and shows the shared **New** divider before the first unread message. - Upcoming reminders appear only in **Reminders**. - When a reminder becomes due, it enters **All** at its trigger time. If its source conversation is already represented, the reminder state merges into that row instead of creating a duplicate; otherwise it appears as a standalone reminder row. - A due reminder can enrich a row in another relative filter when that conversation already qualifies for the filter. Reminder lifecycle remains separate from message read state. - Drafts appear in **All** by their last real edit time. Opening an unchanged draft does not move it to the top. - Reminder and draft rows show their location as `In #channel` or `In DM with `. - **Show unread only** hides reminder and draft work queues because they do not share message unread semantics. ## Removed or narrowed - **Remove the old Activity filter.** It overlapped with All while still omitting items All now includes. - **Narrow Agents.** It no longer gathers every agent participating in a shared thread or subsequent human follow-ups. - **Remove duplicate reminder presentations.** The aggregate pending-reminders jump and duplicate generic feed rows are replaced by one list/detail model. - **Remove Custom and default-view settings from this pass.** They added considerable state and UI before the core model had been validated. - **Do not add section labels for Reminders and Drafts.** A divider communicates the distinction without creating another hierarchy in the menu. ## Risk assessment Medium implementation risk because this changes composition, grouping, ordering, read behavior, and personal queues in a primary desktop view. The implementation is scoped to the desktop UI and its local feed projection; it does not change relay schemas or public APIs. ## Testing - Desktop formatting, lint, file-size, text-size, and TypeScript checks passed. - Desktop unit suite: **3,663 passed, 0 failed**. - Desktop E2E production build passed. - Playwright smoke coverage across every spec touching this surface (`channels`, `smoke`, `profile`, `project-inbox`, `community-rail`, `integration`, `drafts-screenshots`): **118 passed, 0 failed**. - Full Playwright smoke project: **732 passed, 1 skipped**. Three local failures were investigated and cleared — `community-rail` keyboard reorder passed on re-run (flaky), while `relay-reconnect:97` and `video-attachment:223` are untouched by this commit (the only change to shared `tests/helpers/bridge.ts` is a comment) and pass in CI. - Unit coverage includes focused All matching, owned-agent filtering, conversation grouping, oldest-unread selection, selection stability, chronological reminder/draft composition, trigger-time reminder ordering, and duplicate reminder suppression. ## Update: July 27, 2026 The naming decision is settled: the surface stays **Inbox**. An earlier pass in this branch had renamed it to **Activity**; that rename has been reverted in `9c00d2d6e`, which is naming-only and changes no behavior. The revert covers file names, component/hook/type/constant identifiers, the sidebar label and tooltip, the `Inbox options` and `Filter inbox:` aria-labels, and the corresponding test names, test ids, and fixture ids. Three things were deliberately left as `activity`: - **The feed API contract** — the `activity` / `agent_activity` categories, the `feed.activity` and `feed.agentActivity` keys, and the `types=` query parameter. These are the server's names, not the surface's. - **Plain-noun usage** — empty states such as "No activity yet", plus `latestActivityAt` and `PROJECT_ACTIVITY_KINDS`. - **Pre-existing agent, project, and profile activity code**, which refers to a different concept entirely. The earlier experiment-gate approach has also been dropped, so `tests/helpers/bridge.ts` no longer claims that an Activity preview feature exists — `preview-features.json` has no such entry and the seed helper enables every desktop feature. Generated with Codex --------- Signed-off-by: Clay Delk Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 14 +- desktop/playwright.config.ts | 1 + desktop/src/app/AppShell.tsx | 2 +- .../app/useThreadActivityFeedItems.test.mjs | 15 + desktop/src/app/useThreadActivityFeedItems.ts | 3 +- .../agents/knownAgentPubkeys.test.mjs | 28 +- .../src/features/agents/knownAgentPubkeys.ts | 28 + .../channels/useLiveChannelUpdates.ts | 9 +- .../home/lib/homeMessageCapabilities.ts | 31 + .../src/features/home/lib/homePaneLayout.ts | 72 +++ desktop/src/features/home/lib/inbox.test.mjs | 210 ++++++ desktop/src/features/home/lib/inbox.ts | 132 +++- .../features/home/lib/inboxListRows.test.mjs | 122 ++++ .../src/features/home/lib/inboxListRows.ts | 112 ++++ .../features/home/lib/inboxSelection.test.mjs | 53 ++ .../src/features/home/lib/inboxSelection.ts | 21 + .../home/lib/inboxViewHelpers.test.mjs | 158 +++++ .../src/features/home/lib/inboxViewHelpers.ts | 54 +- .../home/ui/HomePersonalInboxDetail.tsx | 45 ++ desktop/src/features/home/ui/HomeView.tsx | 507 +++++++-------- .../src/features/home/ui/InboxDetailPane.tsx | 159 +++-- .../src/features/home/ui/InboxFilterMenu.tsx | 107 +++ .../src/features/home/ui/InboxListPane.tsx | 350 ++++++---- .../src/features/home/ui/InboxMessageRow.tsx | 4 + desktop/src/features/home/useHomeDrafts.ts | 29 +- .../home/useHomeInboxAutoSelection.ts | 73 +++ .../home/useHomeInboxContextMessages.ts | 87 +++ .../home/useHomeInboxReadState.test.mjs | 28 +- .../features/home/useHomeInboxReadState.ts | 39 +- .../src/features/home/useHomePersonalInbox.ts | 88 +++ .../features/home/useInboxThreadContext.ts | 116 +++- .../src/features/home/useOwnedAgentPubkeys.ts | 17 + .../features/reminders/ui/RemindersPanel.tsx | 345 ++++++++-- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 6 +- desktop/tests/e2e/channels.spec.ts | 611 +++++++++++++++++- desktop/tests/e2e/drafts-screenshots.spec.ts | 15 +- .../e2e/inbox-refactor-screenshots.spec.ts | 357 ++++++++++ desktop/tests/e2e/integration.spec.ts | 4 +- desktop/tests/e2e/navigation.spec.ts | 14 +- desktop/tests/e2e/profile.spec.ts | 4 +- desktop/tests/e2e/reminders.spec.ts | 34 +- desktop/tests/e2e/smoke.spec.ts | 73 +-- preview-features.json | 20 +- 43 files changed, 3535 insertions(+), 662 deletions(-) create mode 100644 desktop/src/features/home/lib/homeMessageCapabilities.ts create mode 100644 desktop/src/features/home/lib/homePaneLayout.ts create mode 100644 desktop/src/features/home/lib/inboxListRows.test.mjs create mode 100644 desktop/src/features/home/lib/inboxListRows.ts create mode 100644 desktop/src/features/home/lib/inboxSelection.test.mjs create mode 100644 desktop/src/features/home/lib/inboxSelection.ts create mode 100644 desktop/src/features/home/ui/HomePersonalInboxDetail.tsx create mode 100644 desktop/src/features/home/ui/InboxFilterMenu.tsx create mode 100644 desktop/src/features/home/useHomeInboxAutoSelection.ts create mode 100644 desktop/src/features/home/useHomeInboxContextMessages.ts create mode 100644 desktop/src/features/home/useHomePersonalInbox.ts create mode 100644 desktop/src/features/home/useOwnedAgentPubkeys.ts create mode 100644 desktop/tests/e2e/inbox-refactor-screenshots.spec.ts diff --git a/AGENTS.md b/AGENTS.md index cb4843a9ef..7ff0eb4d47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -343,9 +343,19 @@ Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts` (`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`. +**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri +bridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured` +in `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so +`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails +with `Cannot read properties of undefined (reading 'invoke')` — the app renders +"Community connection failed" instead of the UI under test. That looks exactly +like a product bug rather than a build mistake, so it burns real time. +`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for +you; prefer them over a manual build plus `playwright test`. + **Stale server:** `reuseExistingServer: true` means a previous build's server -serves old code. Kill port 4173 and `pnpm run build` before re-running tests -after code changes. +serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running +tests after code changes. **`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding) must run BEFORE `installMockBridge(page)` — React reads state on mount, the diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 81c65d7910..40dd5dd1b1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -103,6 +103,7 @@ export default defineConfig({ "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", + "**/inbox-refactor-screenshots.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa9348..877cd948ad 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,6 +96,7 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; + const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -105,7 +106,6 @@ export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); - const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); diff --git a/desktop/src/app/useThreadActivityFeedItems.test.mjs b/desktop/src/app/useThreadActivityFeedItems.test.mjs index 6c017028ca..54d36dc1e8 100644 --- a/desktop/src/app/useThreadActivityFeedItems.test.mjs +++ b/desktop/src/app/useThreadActivityFeedItems.test.mjs @@ -113,3 +113,18 @@ test("channel-presence fence applies before mute filter — unknown channel + mu assert.deepEqual(items, []); }); + +test("top-level DM activity is included in Activity", () => { + const dmItem = threadActivityItem({ + id: "agent-dm-reply", + tags: [["h", CHANNEL_ID]], + }); + const channels = [{ id: CHANNEL_ID, name: "Agent DM", channelType: "dm" }]; + + assert.deepEqual( + buildThreadActivityFeedItems([dmItem], new Set(), channels).map( + (item) => item.id, + ), + ["agent-dm-reply"], + ); +}); diff --git a/desktop/src/app/useThreadActivityFeedItems.ts b/desktop/src/app/useThreadActivityFeedItems.ts index af0c50ce48..b14eb51327 100644 --- a/desktop/src/app/useThreadActivityFeedItems.ts +++ b/desktop/src/app/useThreadActivityFeedItems.ts @@ -17,7 +17,8 @@ export function buildThreadActivityFeedItems( // is present in the active community's channel set. Rows persisted under // a different relay's scope key should never reach this function, but // this filter is the last line of defense against cross-community leaks. - if (channelById.get(item.channelId) === undefined) return false; + const channel = channelById.get(item.channelId); + if (channel === undefined) return false; const rootId = getThreadReference(item.tags).rootId; return !rootId || !mutedRootIds.has(rootId); }) diff --git a/desktop/src/features/agents/knownAgentPubkeys.test.mjs b/desktop/src/features/agents/knownAgentPubkeys.test.mjs index 2b7b3e18af..d58b7f5dab 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.test.mjs +++ b/desktop/src/features/agents/knownAgentPubkeys.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mergeKnownAgentPubkeys } from "./knownAgentPubkeys.ts"; +import { + mergeKnownAgentPubkeys, + mergeOwnedAgentPubkeys, +} from "./knownAgentPubkeys.ts"; const MANAGED = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -34,3 +37,26 @@ test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => { assert.deepEqual([...merged], [MANAGED]); }); + +test("owned agents include managed and profile-declared agents", () => { + const merged = mergeOwnedAgentPubkeys( + [{ pubkey: MANAGED }], + { + [RELAY]: { ownerPubkey: " owner " }, + other: { ownerPubkey: "somebody-else" }, + }, + "OWNER", + ); + + assert.deepEqual([...merged].sort(), [MANAGED, RELAY].sort()); +}); + +test("owned agents exclude agents controlled by somebody else", () => { + const merged = mergeOwnedAgentPubkeys( + undefined, + { [RELAY]: { ownerPubkey: "somebody-else" } }, + "owner", + ); + + assert.equal(merged.size, 0); +}); diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts index 978509af09..70cbac68fd 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.ts +++ b/desktop/src/features/agents/knownAgentPubkeys.ts @@ -22,6 +22,34 @@ export function mergeKnownAgentPubkeys( return pubkeys; } +/** Agent identities controlled by the current user. */ +export function mergeOwnedAgentPubkeys( + managedAgents: readonly { pubkey: string }[] | undefined, + profiles: + | Readonly> + | undefined, + currentPubkey: string | null | undefined, +): ReadonlySet { + const pubkeys = new Set(); + for (const agent of managedAgents ?? []) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + + if (!currentPubkey) return pubkeys; + + const ownerPubkey = normalizePubkey(currentPubkey); + for (const [pubkey, profile] of Object.entries(profiles ?? {})) { + if ( + profile.ownerPubkey && + normalizePubkey(profile.ownerPubkey) === ownerPubkey + ) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + + return pubkeys; +} + /** * Channel-scoped variant: the managed ∪ relay baseline plus this channel's * bot members (role `bot` or `isAgent`), so member-only agents are included. diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index f8db0af5e2..aeb3abb905 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -88,6 +88,13 @@ export function isChannelUnreadTriggerKind(kind: number, isDmChannel: boolean) { : UNREAD_TRIGGER_KINDS.has(kind); } +export function isHomeActivityEvent( + isDmChannel: boolean, + isThreadedReply: boolean, +) { + return isThreadedReply || isDmChannel; +} + export function withChannelTagFallback( event: RelayEvent, channelId: string, @@ -278,7 +285,7 @@ export function useLiveChannelUpdates( } } else { options.onChannelMessage?.(channelId, event); - if (isThreadedReply) { + if (isHomeActivityEvent(isDmChannel, isThreadedReply)) { options.onThreadReplyNotification?.(channelId, event); } } diff --git a/desktop/src/features/home/lib/homeMessageCapabilities.ts b/desktop/src/features/home/lib/homeMessageCapabilities.ts new file mode 100644 index 0000000000..effd96e934 --- /dev/null +++ b/desktop/src/features/home/lib/homeMessageCapabilities.ts @@ -0,0 +1,31 @@ +import type { InboxItem } from "@/features/home/lib/inbox"; + +export function getHomeMessageCapabilities( + item: InboxItem | null, + currentPubkey: string | undefined, + availableChannelIds: ReadonlySet, +) { + const canReact = Boolean( + item?.item.channelId && availableChannelIds.has(item.item.channelId), + ); + const canReply = + canReact && item?.item.kind !== 45001 && item?.item.kind !== 45003; + const disabledReplyReason = + canReply || !item + ? null + : item.item.channelId + ? availableChannelIds.has(item.item.channelId) + ? "This item does not support inline replies yet." + : "Open the linked channel to reply." + : "This inbox item does not have a reply target."; + + return { + canDelete: + item !== null && + currentPubkey?.trim().toLowerCase() === + item.item.pubkey.trim().toLowerCase(), + canReact, + canReply, + disabledReplyReason, + }; +} diff --git a/desktop/src/features/home/lib/homePaneLayout.ts b/desktop/src/features/home/lib/homePaneLayout.ts new file mode 100644 index 0000000000..dd4871c888 --- /dev/null +++ b/desktop/src/features/home/lib/homePaneLayout.ts @@ -0,0 +1,72 @@ +import { INBOX_COLUMN_MIN_WIDTH_PX } from "@/features/home/useResizableInboxListWidth"; + +type HomePaneLayoutOptions = { + hasAuxiliaryPane: boolean; + homeWidthPx: number; + inboxListWidthPx: number; + isDrafts: boolean; + isMessagesMode: boolean; + isNarrow: boolean; + isReminders: boolean; + isSinglePanelAuxiliaryView: boolean; + selectedDraft: boolean; + selectedEvent: boolean; + selectedReminder: boolean; + threadPanelWidthPx: number; +}; + +export function getHomePaneLayout(options: HomePaneLayoutOptions) { + const singleMessage = + options.isMessagesMode && + options.isNarrow && + options.selectedEvent && + !options.isSinglePanelAuxiliaryView; + const singleDraft = + options.isDrafts && + options.isNarrow && + options.selectedDraft && + !options.isSinglePanelAuxiliaryView; + const singleReminder = + options.isReminders && + options.isNarrow && + options.selectedReminder && + !options.isSinglePanelAuxiliaryView; + const showList = + !singleMessage && + !singleDraft && + !singleReminder && + !options.isSinglePanelAuxiliaryView; + const showDetail = + !options.isSinglePanelAuxiliaryView && + ((options.isMessagesMode && (!options.isNarrow || singleMessage)) || + (options.isDrafts && (!options.isNarrow || singleDraft)) || + (options.isReminders && (!options.isNarrow || singleReminder))); + const auxiliaryWidth = options.isSinglePanelAuxiliaryView + ? options.homeWidthPx + : options.threadPanelWidthPx; + const maxListWidth = + options.homeWidthPx > 0 + ? Math.max( + INBOX_COLUMN_MIN_WIDTH_PX, + options.homeWidthPx - + INBOX_COLUMN_MIN_WIDTH_PX - + (options.hasAuxiliaryPane ? auxiliaryWidth : 0), + ) + : undefined; + + return { + auxiliaryPaneWidthPx: auxiliaryWidth, + effectiveInboxListWidthPx: + options.homeWidthPx > 0 + ? Math.min( + options.inboxListWidthPx, + maxListWidth ?? options.inboxListWidthPx, + ) + : options.inboxListWidthPx, + isSinglePanelDetailView: singleMessage, + isSinglePanelDraftDetailView: singleDraft, + isSinglePanelReminderDetailView: singleReminder, + showDetailPane: showDetail, + showListPane: showList, + }; +} diff --git a/desktop/src/features/home/lib/inbox.test.mjs b/desktop/src/features/home/lib/inbox.test.mjs index 457e8ecdec..cd2f78c80c 100644 --- a/desktop/src/features/home/lib/inbox.test.mjs +++ b/desktop/src/features/home/lib/inbox.test.mjs @@ -3,11 +3,13 @@ import test from "node:test"; import { buildInboxItems, + findInboxItemByEventId, getInboxConversationId, getInboxTypeLabel, } from "./inbox.ts"; const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const DM_CHANNEL_ID = "8ad375a7-6990-4b22-985f-e3fd34f634d7"; const channels = [ { @@ -15,6 +17,11 @@ const channels = [ name: "buzz-bugs", channelType: "stream", }, + { + id: DM_CHANNEL_ID, + name: "dm-alice", + channelType: "dm", + }, ]; function feedWith(overrides) { @@ -161,6 +168,197 @@ test("thread groups use the latest row label even when the root was a mention", }); }); +test("thread groups resume at the oldest unread reply", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "reply-1", + category: "activity", + content: "Already read reply", + createdAt: 1, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "root-event", "", "reply"], + ], + }), + item({ + id: "reply-2", + category: "activity", + content: "First unread reply", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-1", "", "reply"], + ], + }), + item({ + id: "reply-3", + category: "activity", + content: "Newest unread reply", + createdAt: 3, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-2", "", "reply"], + ], + }), + ], + }), + getThreadReadAt: (rootId) => (rootId === "root-event" ? 1 : null), + }); + + assert.equal(inboxItem.id, "reply-2"); + assert.equal(inboxItem.preview, "First unread reply"); + assert.equal(inboxItem.latestActivityAt, 3); + assert.equal(inboxItem.unreadCount, 2); +}); + +test("thread groups skip an individually read reply when choosing the resume point", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "reply-1", + createdAt: 1, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "root-event", "", "reply"], + ], + }), + item({ + id: "reply-2", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-1", "", "reply"], + ], + }), + ], + }), + getMessageReadAt: (messageId) => (messageId === "reply-1" ? 1 : null), + getThreadReadAt: () => null, + }); + + assert.equal(inboxItem.id, "reply-2"); + assert.equal(inboxItem.unreadCount, 1); +}); + +test("thread groups follow per-message unread state when the aggregate thread marker is newer", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "reply-1", + createdAt: 1, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "root-event", "", "reply"], + ], + }), + item({ + id: "reply-2", + createdAt: 2, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-event", "", "root"], + ["e", "reply-1", "", "reply"], + ], + }), + ], + }), + getMessageReadAt: (messageId) => (messageId === "reply-1" ? 1 : null), + getThreadReadAt: () => 2, + }); + + assert.equal(inboxItem.id, "reply-2"); + assert.equal(inboxItem.unreadCount, 1); +}); + +test("DMs are grouped by channel and represented by the first unread message", () => { + const inboxItems = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "dm-1", + channelId: DM_CHANNEL_ID, + channelType: undefined, + content: "Already read", + createdAt: 1, + tags: [["h", DM_CHANNEL_ID]], + }), + item({ + id: "dm-2", + channelId: DM_CHANNEL_ID, + channelType: undefined, + content: "First unread", + createdAt: 2, + tags: [["h", DM_CHANNEL_ID]], + }), + item({ + id: "dm-3", + channelId: DM_CHANNEL_ID, + channelType: undefined, + content: "Newest unread", + createdAt: 3, + tags: [["h", DM_CHANNEL_ID]], + }), + ], + }), + getChannelReadAt: (channelId) => (channelId === DM_CHANNEL_ID ? 1 : null), + }); + + assert.equal(inboxItems.length, 1); + assert.equal(inboxItems[0].conversationId, `dm:${DM_CHANNEL_ID}`); + assert.equal(inboxItems[0].id, "dm-2"); + assert.equal(inboxItems[0].preview, "First unread"); + assert.equal(inboxItems[0].latestActivityAt, 3); + assert.equal(inboxItems[0].unreadCount, 2); + assert.deepEqual( + inboxItems[0].groupItems.map((groupItem) => groupItem.id), + ["dm-1", "dm-2", "dm-3"], + ); + assert.equal(findInboxItemByEventId(inboxItems, "dm-3"), inboxItems[0]); +}); + +test("a fully read DM conversation falls back to its latest message", () => { + const [inboxItem] = buildInboxItems({ + channels, + feed: feedWith({ + activity: [ + item({ + id: "dm-1", + channelId: DM_CHANNEL_ID, + content: "Older", + createdAt: 1, + tags: [["h", DM_CHANNEL_ID]], + }), + item({ + id: "dm-2", + channelId: DM_CHANNEL_ID, + content: "Latest", + createdAt: 2, + tags: [["h", DM_CHANNEL_ID]], + }), + ], + }), + getChannelReadAt: () => 2, + }); + + assert.equal(inboxItem.id, "dm-2"); + assert.equal(inboxItem.preview, "Latest"); + assert.equal(inboxItem.unreadCount, 0); +}); + // ── conversationId stability tests ────────────────────────────────────────── test("conversationId is stable when a live reply advances the representative", () => { @@ -272,6 +470,18 @@ test("getInboxConversationId falls back to eventId when no root tag", () => { ); }); +test("getInboxConversationId groups direct messages by channel", () => { + assert.equal( + getInboxConversationId( + [["h", DM_CHANNEL_ID]], + "dm-event", + DM_CHANNEL_ID, + "dm", + ), + `dm:${DM_CHANNEL_ID}`, + ); +}); + test("old event still resolves to its conversation row via groupItems", () => { // Demonstrates that findItemByEventId searching groupItems works: the old // root event id is still present in groupItems even when a newer reply diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index a34ea1b66f..e34fa0c200 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -26,7 +26,6 @@ export type InboxFilter = | "mention" | "thread" | "needs_action" - | "activity" | "agent_activity" | "reminders" | "drafts"; @@ -55,6 +54,7 @@ export type InboxItem = { senderLabel: string; subject: string; timestampLabel: string; + unreadCount: number; }; export type InboxTypeLabel = { @@ -238,6 +238,29 @@ export function isThreadActivityItem(item: FeedItem) { return thread.parentId !== null && !isBroadcastReply(item.tags); } +function isThreadReplyItem(item: FeedItem) { + const thread = getThreadReference(item.tags); + return thread.parentId !== null && !isBroadcastReply(item.tags); +} + +function uniqueItemsById(items: readonly FeedItem[]) { + const seen = new Set(); + return items.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); +} + +function isItemUnread( + item: FeedItem, + readAt: number | null, + getMessageReadAt?: (messageId: string) => number | null, +) { + const messageReadAt = getMessageReadAt?.(item.id) ?? null; + return item.createdAt > Math.max(readAt ?? 0, messageReadAt ?? 0); +} + function activityHeadline(item: FeedItem) { return feedHeadline(item); } @@ -338,18 +361,30 @@ function categoryPriority(category: FeedItemCategory) { } } -function getInboxThreadKey(item: FeedItem) { +function getInboxThreadKey( + item: FeedItem, + channelById: ReadonlyMap, +) { const projectReference = getProjectInboxReference(item); if (projectReference) { return `project:${projectReference.repoAddress}:${projectReference.rootId}`; } - const thread = getThreadReference(item.tags); - return thread.rootId ?? thread.parentId ?? item.id; + const channelType = resolveItemChannel(item, channelById).type; + return getInboxConversationId( + item.tags, + item.id, + item.channelId, + channelType, + item.kind, + ); } -function getStableConversationId(item: FeedItem) { - return getInboxItemConversationId(item); +function getStableConversationId( + item: FeedItem, + channelById: ReadonlyMap, +) { + return getInboxThreadKey(item, channelById); } /** @@ -361,6 +396,8 @@ function getStableConversationId(item: FeedItem) { export function getInboxConversationId( tags: string[][], eventId: string, + channelId?: string | null, + channelType?: string, kind?: number, ): string { if (kind !== undefined) { @@ -374,13 +411,37 @@ export function getInboxConversationId( } } + if (channelType === "dm" && channelId) { + return `dm:${channelId}`; + } + const thread = getThreadReference(tags); return thread.rootId ?? thread.parentId ?? eventId; } /** Returns the stable conversation identity for a complete Inbox feed item. */ export function getInboxItemConversationId(item: FeedItem) { - return getInboxConversationId(item.tags, item.id, item.kind); + return getInboxConversationId( + item.tags, + item.id, + item.channelId, + item.channelType, + item.kind, + ); +} + +/** Finds the Inbox row containing an event, including grouped events. */ +export function findInboxItemByEventId( + items: readonly InboxItem[], + eventId: string, +): InboxItem | null { + return ( + items.find((item) => item.id === eventId) ?? + items.find((item) => + item.groupItems.some((groupItem) => groupItem.id === eventId), + ) ?? + null + ); } function formatInboxTimestamp(unixSeconds: number) { @@ -450,11 +511,20 @@ export function buildInboxItems({ channels, currentPubkey, feed, + getChannelReadAt, + getMessageReadAt, + getThreadReadAt, profiles, }: { channels?: InboxChannel[]; currentPubkey?: string; feed?: HomeFeedResponse; + getChannelReadAt?: (channelId: string) => number | null; + getMessageReadAt?: (messageId: string) => number | null; + getThreadReadAt?: ( + rootId: string, + channelId?: string | null, + ) => number | null; profiles?: UserProfileLookup; }): InboxItem[] { if (!feed) { @@ -493,7 +563,7 @@ export function buildInboxItems({ >(); for (const item of feedItems) { - const threadKey = getInboxThreadKey(item); + const threadKey = getInboxThreadKey(item, channelById); const group = threadGroups.get(threadKey) ?? { items: [], latestActivityAt: 0, @@ -502,7 +572,7 @@ export function buildInboxItems({ group.items.push(item); group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt); - if (item.id === getStableConversationId(item)) { + if (item.id === getStableConversationId(item, channelById)) { group.rootItem = item; } @@ -514,11 +584,49 @@ export function buildInboxItems({ ([, left], [, right]) => right.latestActivityAt - left.latestActivityAt, ) .map(([, group]) => { - const conversationId = getStableConversationId(group.items[0]); + const conversationId = getStableConversationId( + group.items[0], + channelById, + ); const latestItem = group.items.reduce((latest, current) => current.createdAt > latest.createdAt ? current : latest, ); - const item = latestItem; + const groupChannel = resolveGroupChannel( + latestItem, + group.items, + channelById, + ); + const groupChannelId = group.items.find( + (candidate) => candidate.channelId, + )?.channelId; + const channelReadAt = + groupChannel.type === "dm" && groupChannelId && getChannelReadAt + ? getChannelReadAt(groupChannelId) + : undefined; + const uniqueGroupItems = uniqueItemsById(group.items); + const threadReplyItems = uniqueGroupItems.filter(isThreadReplyItem); + const threadReadAt = + groupChannel.type !== "dm" && + threadReplyItems.length > 0 && + getThreadReadAt + ? getThreadReadAt(conversationId, groupChannelId) + : undefined; + const unreadItems = ( + channelReadAt !== undefined + ? uniqueGroupItems.filter((candidate) => + isItemUnread(candidate, channelReadAt), + ) + : threadReplyItems.length > 0 && getMessageReadAt + ? threadReplyItems.filter((candidate) => + isItemUnread(candidate, null, getMessageReadAt), + ) + : threadReadAt !== undefined + ? threadReplyItems.filter((candidate) => + isItemUnread(candidate, threadReadAt), + ) + : [] + ).sort((left, right) => left.createdAt - right.createdAt); + const item = unreadItems[0] ?? latestItem; const categories = [ ...new Set(group.items.map((groupItem) => groupItem.category)), ].sort((left, right) => categoryPriority(left) - categoryPriority(right)); @@ -534,7 +642,6 @@ export function buildInboxItems({ item.tags, profiles, ); - const groupChannel = resolveGroupChannel(item, group.items, channelById); const channelLabel = groupChannel.name; const displayItem: FeedItem = { ...item, @@ -561,6 +668,7 @@ export function buildInboxItems({ senderLabel, subject, timestampLabel: formatInboxTimestamp(group.latestActivityAt), + unreadCount: unreadItems.length, }; }); } diff --git a/desktop/src/features/home/lib/inboxListRows.test.mjs b/desktop/src/features/home/lib/inboxListRows.test.mjs new file mode 100644 index 0000000000..c4bbcdfff0 --- /dev/null +++ b/desktop/src/features/home/lib/inboxListRows.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInboxListRows } from "./inboxListRows.ts"; + +function inboxItem( + id, + latestActivityAt, + conversationId = `conversation:${id}`, +) { + return { + conversationId, + groupItems: [], + id, + item: { id }, + latestActivityAt, + }; +} + +function draftItem(key, updatedAt, rootStatus = "available") { + return { + entry: { + key, + draft: { createdAt: updatedAt, updatedAt }, + }, + rootStatus, + }; +} + +function reminder( + id, + createdAt, + status = "pending", + { eventId, notBefore } = {}, +) { + return { + id, + createdAt, + notBefore, + content: { + status, + target: eventId ? { eventId } : undefined, + }, + }; +} + +test("Inbox All combines rows in latest-first order", () => { + const rows = buildInboxListRows({ + drafts: [draftItem("draft", "2026-07-21T12:00:00.000Z")], + items: [inboxItem("message", 1_753_099_300)], + reminders: [reminder("reminder", 1_753_099_100)], + }); + + assert.deepEqual( + rows.map((row) => row.kind), + ["draft", "inbox", "reminder"], + ); +}); + +test("Inbox All excludes completed reminders and deleted-root drafts", () => { + const rows = buildInboxListRows({ + drafts: [draftItem("deleted", "2026-07-21T12:00:00.000Z", "deleted")], + items: [], + reminders: [reminder("done", 1_753_099_100, "done")], + }); + + assert.deepEqual(rows, []); +}); + +test("Inbox conversation keys stay stable when the representative changes", () => { + const first = buildInboxListRows({ + drafts: [], + items: [inboxItem("reply-1", 1, "thread-root")], + reminders: [], + }); + const second = buildInboxListRows({ + drafts: [], + items: [inboxItem("reply-2", 2, "thread-root")], + reminders: [], + }); + + assert.equal(first[0].key, "inbox:thread-root"); + assert.equal(second[0].key, first[0].key); +}); + +test("due reminder enriches its existing conversation instead of duplicating it", () => { + const item = inboxItem("message", 100); + item.groupItems = [{ id: "reminded-reply" }]; + const rows = buildInboxListRows({ + drafts: [], + items: [item], + reminders: [ + reminder("reminder", 50, "pending", { + eventId: "reminded-reply", + notBefore: 200, + }), + ], + }); + + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, "inbox"); + assert.equal(rows[0].dueReminder?.id, "reminder"); + assert.equal(rows[0].sortAt, 200); +}); + +test("due reminder without a represented conversation sorts at trigger time", () => { + const rows = buildInboxListRows({ + drafts: [], + items: [inboxItem("newer-than-creation", 150)], + reminders: [ + reminder("reminder", 50, "pending", { + eventId: "not-in-feed", + notBefore: 200, + }), + ], + }); + + assert.deepEqual( + rows.map((row) => row.kind), + ["reminder", "inbox"], + ); +}); diff --git a/desktop/src/features/home/lib/inboxListRows.ts b/desktop/src/features/home/lib/inboxListRows.ts new file mode 100644 index 0000000000..499ff96eab --- /dev/null +++ b/desktop/src/features/home/lib/inboxListRows.ts @@ -0,0 +1,112 @@ +import type { InboxItem } from "@/features/home/lib/inbox"; +import type { DraftViewItem } from "@/features/messages/ui/DraftsPanel"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; + +export type InboxListRow = + | { + key: string; + kind: "inbox"; + item: InboxItem; + dueReminder?: Reminder; + sortAt: number; + } + | { + key: string; + kind: "reminder"; + reminder: Reminder; + sortAt: number; + } + | { + key: string; + kind: "draft"; + item: DraftViewItem; + sortAt: number; + }; + +function draftActivityAt(item: DraftViewItem): number { + for (const value of [ + item.entry.draft.updatedAt, + item.entry.draft.createdAt, + ]) { + const timestamp = Date.parse(value); + if (Number.isFinite(timestamp)) return timestamp / 1_000; + } + return 0; +} + +export function buildInboxListRows({ + drafts, + items, + reminders, +}: { + drafts: readonly DraftViewItem[]; + items: readonly InboxItem[]; + reminders: readonly Reminder[]; +}): InboxListRow[] { + const consumedReminderIds = new Set(); + const inboxRows = items.map((item): InboxListRow => { + const eventIds = new Set([ + item.id, + item.item.id, + ...item.groupItems.map((groupItem) => groupItem.id), + ]); + const matchingReminders = reminders + .filter( + (reminder) => + reminder.content.status === "pending" && + Boolean( + reminder.content.target?.eventId && + eventIds.has(reminder.content.target.eventId), + ), + ) + .sort( + (left, right) => + (right.notBefore ?? right.createdAt) - + (left.notBefore ?? left.createdAt), + ); + const dueReminder = matchingReminders[0]; + + for (const reminder of matchingReminders) { + consumedReminderIds.add(reminder.id); + } + + return { + key: `inbox:${item.conversationId}`, + kind: "inbox", + item, + dueReminder, + sortAt: Math.max( + item.latestActivityAt, + dueReminder?.notBefore ?? dueReminder?.createdAt ?? 0, + ), + }; + }); + + return [ + ...inboxRows, + ...reminders + .filter( + (reminder) => + reminder.content.status === "pending" && + !consumedReminderIds.has(reminder.id), + ) + .map( + (reminder): InboxListRow => ({ + key: `reminder:${reminder.id}`, + kind: "reminder", + reminder, + sortAt: reminder.notBefore ?? reminder.createdAt, + }), + ), + ...drafts + .filter((item) => item.rootStatus !== "deleted") + .map( + (item): InboxListRow => ({ + key: `draft:${item.entry.key}`, + kind: "draft", + item, + sortAt: draftActivityAt(item), + }), + ), + ].sort((left, right) => right.sortAt - left.sortAt); +} diff --git a/desktop/src/features/home/lib/inboxSelection.test.mjs b/desktop/src/features/home/lib/inboxSelection.test.mjs new file mode 100644 index 0000000000..84088f743a --- /dev/null +++ b/desktop/src/features/home/lib/inboxSelection.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveInboxFilterSelection } from "./inboxSelection.ts"; + +const items = [ + { conversationId: "first-conversation", id: "first-event" }, + { conversationId: "second-conversation", id: "second-event" }, +]; + +test("filter selection preserves a conversation that remains visible", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: false, + items, + selectedConversationId: "second-conversation", + }), + { autoSelectedEventId: null, preserveSelection: true }, + ); +}); + +test("wide filter selection immediately selects the first valid row", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: false, + items, + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: "first-event", preserveSelection: false }, + ); +}); + +test("narrow filter selection returns to the list when selection is invalid", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: true, + items, + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: null, preserveSelection: false }, + ); +}); + +test("empty filter selection clears detail at every width", () => { + assert.deepEqual( + resolveInboxFilterSelection({ + isNarrow: false, + items: [], + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: null, preserveSelection: false }, + ); +}); diff --git a/desktop/src/features/home/lib/inboxSelection.ts b/desktop/src/features/home/lib/inboxSelection.ts new file mode 100644 index 0000000000..c7c6217a2f --- /dev/null +++ b/desktop/src/features/home/lib/inboxSelection.ts @@ -0,0 +1,21 @@ +import type { InboxItem } from "@/features/home/lib/inbox"; + +export function resolveInboxFilterSelection({ + isNarrow, + items, + selectedConversationId, +}: { + isNarrow: boolean; + items: readonly Pick[]; + selectedConversationId: string | null; +}) { + const preserveSelection = + selectedConversationId !== null && + items.some((item) => item.conversationId === selectedConversationId); + + return { + autoSelectedEventId: + preserveSelection || isNarrow ? null : (items[0]?.id ?? null), + preserveSelection, + }; +} diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index e76b551239..2cd6424ab5 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -4,14 +4,67 @@ import test from "node:test"; import { formatTimelineMessages } from "../../messages/lib/formatTimelineMessages.ts"; import { getConfigNudgeAuthorPubkey } from "../../messages/ui/configNudgeAuthPubkey.ts"; import { + filterInboxItems, getContextMessageDepth, getReactionTargetId, + hasInboxThreadContext, isInboxThreadContextEvent, + matchesInboxAllView, matchesInboxFilter, toInboxContextMessage, toTimelineMessage, } from "./inboxViewHelpers.ts"; +test("Inbox uses the dedicated reminder list instead of feed reminder rows", () => { + const message = { item: { kind: 9 } }; + const reminder = { item: { kind: 40007 } }; + const items = [message, reminder]; + + assert.deepEqual(filterInboxItems(items), [message]); +}); + +test("hasInboxThreadContext finds replies in the grouped row or loaded context", () => { + const root = { tags: [["h", "channel"]] }; + const reply = { + tags: [ + ["h", "channel"], + ["e", "root", "", "reply"], + ], + }; + + assert.equal( + hasInboxThreadContext({ item: root, groupItems: [root, reply] }), + true, + ); + assert.equal( + hasInboxThreadContext({ item: root, groupItems: [root] }, [reply]), + true, + ); +}); + +test("hasInboxThreadContext keeps standalone and broadcast activity unthreaded", () => { + const root = { tags: [["h", "channel"]] }; + const broadcastReply = { + tags: [ + ["h", "channel"], + ["e", "root", "", "reply"], + ["broadcast", "1"], + ], + }; + + assert.equal( + hasInboxThreadContext({ item: root, groupItems: [root] }), + false, + ); + assert.equal( + hasInboxThreadContext({ + item: broadcastReply, + groupItems: [broadcastReply], + }), + false, + ); +}); + // --- matchesInboxFilter --- test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => { @@ -19,6 +72,85 @@ test("matchesInboxFilter returns true for the 'all' filter regardless of categor assert.equal(matchesInboxFilter({ categories: ["mentions"] }, "all"), true); }); +test("Inbox All excludes generic top-level channel traffic", () => { + const owned = new Set(["owned-agent"]); + assert.equal( + matchesInboxAllView( + { + categories: ["activity"], + item: { + channelType: "stream", + pubkey: "human", + tags: [["h", "channel"]], + }, + }, + owned, + ), + false, + ); +}); + +test("Inbox All includes each personally relevant message source", () => { + const owned = new Set(["owned-agent"]); + const cases = [ + { + categories: ["activity"], + item: { channelType: "dm", pubkey: "human", tags: [] }, + }, + { + categories: ["mention"], + item: { channelType: "stream", pubkey: "human", tags: [] }, + }, + { + categories: ["needs_action"], + item: { channelType: "stream", pubkey: "human", tags: [] }, + }, + { + categories: ["activity"], + item: { + channelType: "stream", + pubkey: "human", + tags: [["e", "root", "", "reply"]], + }, + }, + { + categories: ["activity"], + item: { channelType: "stream", pubkey: "OWNED-AGENT", tags: [] }, + }, + { + categories: ["activity"], + item: { + channelType: null, + id: "project-pull-request", + kind: 1618, + pubkey: "human", + tags: [["a", `30617:${"a".repeat(64)}:buzz`]], + }, + }, + ]; + + for (const item of cases) { + assert.equal(matchesInboxAllView(item, owned), true); + } +}); + +test("Inbox All excludes generic updates from agents the user does not own", () => { + assert.equal( + matchesInboxAllView( + { + categories: ["agent_activity"], + item: { + channelType: "stream", + pubkey: "somebody-elses-agent", + tags: [], + }, + }, + new Set(["owned-agent"]), + ), + false, + ); +}); + test("matchesInboxFilter matches when the category is present", () => { assert.equal( matchesInboxFilter({ categories: ["mentions", "activity"] }, "mentions"), @@ -34,6 +166,32 @@ test("matchesInboxFilter is false when the category is absent", () => { assert.equal(matchesInboxFilter({ categories: [] }, "mentions"), false); }); +test("owned-agent filtering uses the representative event author", () => { + const owned = new Set(["owned-agent"]); + assert.equal( + matchesInboxFilter( + { + categories: ["activity"], + item: { pubkey: "OWNED-AGENT" }, + }, + "agent_activity", + owned, + ), + true, + ); + assert.equal( + matchesInboxFilter( + { + categories: ["agent_activity"], + item: { pubkey: "somebody-elses-agent" }, + }, + "agent_activity", + owned, + ), + false, + ); +}); + test("matchesInboxFilter matches thread rows by thread tags", () => { const replyItem = { id: "reply", diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 869dbf1818..42ed8e1a5a 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -2,6 +2,7 @@ import { formatInboxFullTimestamp, type InboxContextMessage, type InboxFilter, + type InboxItem, } from "@/features/home/lib/inbox"; import { isProjectInboxItem } from "@/features/home/lib/projectInbox"; import { @@ -15,6 +16,8 @@ import type { RelayEvent, UserProfileSummary, } from "@/shared/api/types"; +import { KIND_REMINDER } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; function hasThreadReplyTags(tags: string[][]) { @@ -22,6 +25,19 @@ function hasThreadReplyTags(tags: string[][]) { return thread.parentId !== null && !isBroadcastReply(tags); } +export function filterInboxItems(items: InboxItem[]) { + return items.filter((item) => item.item.kind !== KIND_REMINDER); +} + +export function hasInboxThreadContext( + item: Pick, + contextMessages: readonly Pick[] = [], +) { + return [item.item, ...item.groupItems, ...contextMessages].some((event) => + hasThreadReplyTags(event.tags ?? []), + ); +} + export function matchesInboxFilter( item: { categories: readonly string[]; @@ -29,9 +45,12 @@ export function matchesInboxFilter( item?: FeedItem; }, filter: InboxFilter, + ownedAgentPubkeys?: ReadonlySet, ) { if (filter === "all") { - return true; + return ownedAgentPubkeys + ? matchesInboxAllView(item, ownedAgentPubkeys) + : true; } if (filter === "thread") { @@ -46,9 +65,42 @@ export function matchesInboxFilter( ); } + if (filter === "agent_activity" && ownedAgentPubkeys) { + const representative = item.item ?? item.groupItems?.at(-1); + return representative + ? ownedAgentPubkeys.has(normalizePubkey(representative.pubkey)) + : false; + } + return item.categories.includes(filter); } +export function matchesInboxAllView( + item: { + categories: readonly string[]; + groupItems?: readonly FeedItem[]; + item?: FeedItem; + }, + ownedAgentPubkeys: ReadonlySet, +): boolean { + const representative = item.item ?? item.groupItems?.at(-1); + return ( + representative?.channelType === "dm" || + item.categories.includes("mention") || + [item.item, ...(item.groupItems ?? [])].some((groupItem) => + groupItem ? hasThreadReplyTags(groupItem.tags) : false, + ) || + [item.item, ...(item.groupItems ?? [])].some( + (groupItem) => groupItem && isProjectInboxItem(groupItem), + ) || + item.categories.includes("needs_action") || + Boolean( + representative && + ownedAgentPubkeys.has(normalizePubkey(representative.pubkey)), + ) + ); +} + export function getContextMessageDepth( event: RelayEvent, eventById: ReadonlyMap, diff --git a/desktop/src/features/home/ui/HomePersonalInboxDetail.tsx b/desktop/src/features/home/ui/HomePersonalInboxDetail.tsx new file mode 100644 index 0000000000..044c768961 --- /dev/null +++ b/desktop/src/features/home/ui/HomePersonalInboxDetail.tsx @@ -0,0 +1,45 @@ +import type { DraftViewItem } from "@/features/messages/ui/DraftsPanel"; +import { DraftDetailPane } from "@/features/messages/ui/DraftDetailPane"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { ReminderDetailPane } from "@/features/reminders/ui/RemindersPanel"; + +type HomePersonalInboxDetailProps = { + currentPubkey?: string; + draftItem: DraftViewItem | null; + mode: "drafts" | "reminders"; + onBack?: () => void; + onDeleteDraft: (draftKey: string) => void; + reminder: Reminder | null; +}; + +export function HomePersonalInboxDetail({ + currentPubkey, + draftItem, + mode, + onBack, + onDeleteDraft, + reminder, +}: HomePersonalInboxDetailProps) { + if (mode === "drafts") { + return ( + + ); + } + + if (mode === "reminders") { + return ( + + ); + } + + return null; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 2bcb231e9d..0f7c851643 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -9,21 +9,23 @@ import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { type InboxFilter, - type InboxContextMessage, - type InboxItem, type InboxReply, buildInboxItems, + findInboxItemByEventId, formatInboxFullTimestamp, getInboxItemConversationId, } from "@/features/home/lib/inbox"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; +import { useOwnedAgentPubkeys } from "@/features/home/useOwnedAgentPubkeys"; import { - getReactionTargetId, + filterInboxItems, matchesInboxFilter, - toInboxContextMessage, } from "@/features/home/lib/inboxViewHelpers"; +import { resolveInboxFilterSelection } from "@/features/home/lib/inboxSelection"; import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; -import { useHomeDrafts } from "@/features/home/useHomeDrafts"; +import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelection"; +import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages"; +import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; import { type ProfilePanelTab, @@ -35,32 +37,26 @@ import { profilePanelViewFromSearch, } from "@/features/profile/ui/UserProfilePanelUtils"; import { - INBOX_COLUMN_MIN_WIDTH_PX, INBOX_SINGLE_COLUMN_BREAKPOINT_PX, useResizableInboxListWidth, } from "@/features/home/useResizableInboxListWidth"; +import { getHomePaneLayout } from "@/features/home/lib/homePaneLayout"; +import { getHomeMessageCapabilities } from "@/features/home/lib/homeMessageCapabilities"; import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; -import { DraftDetailPane } from "@/features/messages/ui/DraftDetailPane"; +import { HomePersonalInboxDetail } from "@/features/home/ui/HomePersonalInboxDetail"; import { useChannelMessagesQuery, useToggleReactionMutation, } from "@/features/messages/hooks"; -import { - collectMessageMentionPubkeys, - formatTimelineMessages, -} from "@/features/messages/lib/formatTimelineMessages"; +import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; import { formatTime } from "@/features/messages/lib/dateFormatters"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/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"; @@ -82,27 +78,6 @@ const INBOX_SEARCH_KEYS = [ "profileView", ] as const; -/** - * Finds the InboxItem whose stable conversation contains the given event ID. - * Checks `item.id` (the current representative/latest event) first, then - * falls back to `item.groupItems` so that a deep-linked or URL-anchored event - * that is no longer the representative still resolves to its row. - */ -function findItemByEventId( - items: readonly InboxItem[], - eventId: string, -): InboxItem | null { - // Fast path: representative event matches (the common case). - const direct = items.find((item) => item.id === eventId); - if (direct) return direct; - // Slow path: event is a non-representative group member (e.g. original - // mention that was later superseded by a newer reply as the representative). - return ( - items.find((item) => item.groupItems.some((gi) => gi.id === eventId)) ?? - null - ); -} - type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; @@ -142,18 +117,29 @@ export function HomeView({ const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; const isMessagesMode = !isReminders && !isDrafts; - const remindersQuery = useRemindersQuery(currentPubkey); - const dueReminderCount = countDueReminders(remindersQuery.data ?? []); + const allowMixedPersonalSelection = filter === "all"; const { - activeCount: activeDraftCount, - deleteDraft: handleDeleteDraft, - items: draftItems, - selectedItem: selectedDraftItem, - selectedKey: selectedDraftKey, - selectDraft: setSelectedDraftKey, - } = useHomeDrafts({ + drafts: { + activeCount: activeDraftCount, + deleteDraft: handleDeleteDraft, + items: draftItems, + selectedItem: selectedDraftItem, + selectedKey: selectedDraftKey, + selectDraft: setSelectedDraftKey, + }, + dueReminderCount, + pendingReminders, + reminders: { + selectedId: selectedReminderId, + selectedItem: selectedReminder, + select: setSelectedReminderId, + }, + } = useHomePersonalInbox({ + allowMixedSelection: allowMixedPersonalSelection, + currentPubkey, isDrafts, isNarrowHomeViewport, + isReminders, viewportWidthPx: homeInboxWidthPx, }); // `?item=` is Messages-mode-only machinery: a reminder never enters the @@ -167,37 +153,23 @@ export function HomeView({ const profilePanelView = profilePanelViewFromSearch( inboxSearchValues.profileView, ); - // Selection state — two-tier design so explicit and automatic selections - // have distinct ownership: - // - // urlSelectedItemId — explicit/user anchor, URL-authoritative. Written - // only by handleUserSelectItem (via applyInboxSearchPatch) and by - // back/forward navigation. Never touched by background data loads. - // - // autoSelectedEventId — default desktop selection when the URL carries no - // explicit anchor. Written only by the auto-selection effect. Never - // triggers a history push. - // - // selectedEventId — the effective anchor used everywhere below: the URL - // anchor when present, otherwise the auto-selected fallback. Derived - // synchronously, no separate state — so there is no mirror-revert race. + // Explicit selection is URL-owned; automatic desktop selection stays local. const [autoSelectedEventId, setAutoSelectedEventId] = React.useState< string | null >(null); + const [unreadBoundary, setUnreadBoundary] = React.useState<{ + conversationId: string; + eventId: string; + } | null>(null); const selectedEventId = urlSelectedItemId ?? autoSelectedEventId; const [managedChannelId, setManagedChannelId] = React.useState( null, ); const { goChannel } = useAppNavigation(); const openDmMutation = useOpenDmMutation(); - // handleUserSelectItem: explicit selection — only patches the URL. - // No local setSelectedEventId call; the URL patch triggers a TanStack Router - // navigation which updates urlSelectedItemId, which becomes selectedEventId - // on the next render. This avoids the mirror-revert race where - // useEffect([urlSelectedItemId]) would fire before navigation commits and - // overwrite the optimistically-set local state with the stale URL null. const handleUserSelectItem = React.useCallback( (itemId: string | null) => { + setAutoSelectedEventId(null); applyInboxSearchPatch({ item: itemId }); }, [applyInboxSearchPatch], @@ -267,6 +239,7 @@ export function HomeView({ getMessageReadAt, feedItemState, markChannelRead, + markMessageRead, markThreadRead, readStateVersion, } = useAppShell(); @@ -290,7 +263,6 @@ export function HomeView({ ? (getThreadReference(activeLatchedItem.tags).parentId ?? activeLatchedItem.id) : null; - const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data; const selectedChannelIdCandidate = React.useMemo(() => { @@ -321,6 +293,13 @@ export function HomeView({ const threadContext = useInboxThreadContext( threadContextFeedItem, channelMessages, + { + fullChannel: + selectedChannel?.channelType === "dm" || + threadContextFeedItem?.channelType === "dm", + hasChannelLoadError: channelMessagesQuery.isError, + isChannelLoading: channelMessagesQuery.isPending, + }, ); const feedProfilePubkeys = React.useMemo( @@ -342,6 +321,11 @@ export function HomeView({ enabled: feedProfilePubkeys.length > 0, }); const feedProfiles = feedProfilesQuery.data?.profiles; + const ownedAgentPubkeys = useOwnedAgentPubkeys( + true, + feedProfiles, + currentPubkey, + ); const feedOwnerPubkeys = React.useMemo( () => [ ...new Set( @@ -356,8 +340,6 @@ export function HomeView({ enabled: feedOwnerPubkeys.length > 0, }); const feedOwnerProfiles = feedOwnerProfilesQuery.data?.profiles; - // Agent set for the inbox list/detail bot badges: the community-scoped - // baseline widened with this surface's profile lookup. const communityAgentPubkeys = useKnownAgentPubkeys(); const inboxAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(communityAgentPubkeys); @@ -370,16 +352,28 @@ export function HomeView({ return pubkeys; }, [feedProfiles, communityAgentPubkeys]); - const inboxItems = React.useMemo( - () => - buildInboxItems({ - channels, - currentPubkey, - feed, - profiles: feedProfiles, - }), - [channels, currentPubkey, feed, feedProfiles], - ); + // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates the stable getChannelReadAt callback + const inboxItems = React.useMemo(() => { + const items = buildInboxItems({ + channels, + currentPubkey, + feed, + getChannelReadAt, + getMessageReadAt, + getThreadReadAt, + profiles: feedProfiles, + }); + return filterInboxItems(items); + }, [ + channels, + currentPubkey, + feed, + feedProfiles, + getChannelReadAt, + getMessageReadAt, + getThreadReadAt, + readStateVersion, + ]); const { effectiveDoneSet, markItemRead, markItemUnread } = useHomeInboxReadState({ items: inboxItems, @@ -390,20 +384,18 @@ export function HomeView({ localDoneSet: doneSet, localUnreadSet: unreadSet, markChannelRead, + markMessageRead, markThreadRead, markDoneLocal: markDone, markUnreadLocal: markUnread, undoDoneLocal: undoDone, undoUnreadLocal: undoUnread, }); - // Resolve the selected row and stable conversation ID from inboxItems - // (unfiltered). We need conversationId before filtering so we can keep the - // selected item visible when unreadOnly is on. The event anchor may point to - // any event in the group (representative or older member), so search both. + // Resolve selection before filtering so unread-only can retain its active row. const selectedItemFromAll = React.useMemo( () => selectedEventId - ? (findItemByEventId(inboxItems, selectedEventId) ?? null) + ? findInboxItemByEventId(inboxItems, selectedEventId) : null, [inboxItems, selectedEventId], ); @@ -421,7 +413,7 @@ export function HomeView({ const filteredItems = React.useMemo(() => { return inboxItems.filter( (item) => - matchesInboxFilter(item, filter) && + matchesInboxFilter(item, filter, ownedAgentPubkeys) && (!unreadOnly || !effectiveDoneSet.has(item.id) || item.conversationId === selectedConversationId), @@ -430,99 +422,45 @@ export function HomeView({ effectiveDoneSet, filter, inboxItems, + ownedAgentPubkeys, selectedConversationId, unreadOnly, ]); - // Prefer the filtered view for the selected item so that filter/unread - // changes can still dismiss it, but fall back to the unfiltered row so a - // live representative-event change (which keeps the conversation in the - // filter) does not make selectedItem go null mid-session. + // A filter change may only retain detail for a conversation that remains + // visible. The filter handler selects the next valid row in the same update, + // so the detail pane never renders a stale conversation between states. const selectedItem = React.useMemo(() => { if (!selectedEventId) return null; - // Primary: find by event anchor in the filtered view. - const fromFiltered = findItemByEventId(filteredItems, selectedEventId); + const fromFiltered = findInboxItemByEventId(filteredItems, selectedEventId); if (fromFiltered) return fromFiltered; - // Secondary: event anchor is in an unfiltered row (e.g., dismissed item). - if (selectedItemFromAll) return selectedItemFromAll; - // Tertiary: anchor has been displaced from all groupItems (e.g., a very old - // event that fell off the feed window). Resolve by conversationId so the - // correct row stays selected and the auto-selection effect doesn't replace - // the anchor with a different conversation. if (selectedConversationId) { return ( filteredItems.find( (item) => item.conversationId === selectedConversationId, - ) ?? - inboxItems.find( - (item) => item.conversationId === selectedConversationId, - ) ?? - null + ) ?? null ); } return null; - }, [ - filteredItems, - inboxItems, - selectedConversationId, - selectedEventId, - selectedItemFromAll, - ]); - const contextMessages = React.useMemo(() => { - if (!selectedItem) { - return []; + }, [filteredItems, selectedConversationId, selectedEventId]); + const unreadBoundaryEventId = React.useMemo(() => { + if (!selectedItem) return null; + if (unreadBoundary?.conversationId === selectedItem.conversationId) { + return unreadBoundary.eventId; } - - const eventById = new Map( - threadContext.events.map((event) => [event.id, event]), - ); - const contextEventIds = new Set(eventById.keys()); - const reactionEvents = [ - ...(channelMessages ?? []), - ...threadContext.reactionEvents, - ].filter((event) => { - if (event.kind !== KIND_REACTION) { - return false; - } - - const targetId = getReactionTargetId(event.tags); - return Boolean(targetId && contextEventIds.has(targetId)); - }); - const currentUserAvatarUrl = currentPubkey - ? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null) - : null; - const timelineMessages = formatTimelineMessages( - [...threadContext.events, ...reactionEvents], - selectedChannel, - currentPubkey, - currentUserAvatarUrl, - feedProfiles, - undefined, - undefined, - undefined, - relaySelfPubkey, - feedOwnerProfiles, - ); - - return timelineMessages.map((message) => - toInboxContextMessage(message, { - eventById, - fallbackAuthorPubkey: selectedItem.item.pubkey, - profiles: feedProfiles, - selectedItemId: selectedEventId ?? selectedItem.id, - }), - ); - }, [ + return effectiveDoneSet.has(selectedItem.id) ? null : selectedItem.id; + }, [effectiveDoneSet, selectedItem, unreadBoundary]); + const contextMessages = useHomeInboxContextMessages({ channelMessages, currentPubkey, - feedProfiles, - feedOwnerProfiles, + events: threadContext.events, + ownerProfiles: feedOwnerProfiles, + profiles: feedProfiles, + reactionEvents: threadContext.reactionEvents, relaySelfPubkey, selectedChannel, selectedEventId, selectedItem, - threadContext.events, - threadContext.reactionEvents, - ]); + }); const selectedItemReplies = React.useMemo(() => { if (!selectedItem) return []; const localReplies = @@ -530,73 +468,20 @@ export function HomeView({ const contextIds = new Set(contextMessages.map((message) => message.id)); return localReplies.filter((reply) => !contextIds.has(reply.id)); }, [contextMessages, localRepliesByItemId, selectedItem]); - React.useEffect(() => { - // Auto-selection is Messages-mode-only: in Reminders mode no FeedItem is - // ever selected, so default-selecting one behind the reminders list would - // be wasted work and could drive narrow-viewport detail off a stale feed - // selection. - if (!isMessagesMode) { - return; - } - - // The URL carries an explicit anchor — auto-selection must not overwrite - // it. Clear any stale auto fallback so it cannot reappear if back later - // returns to a no-item entry. - if (urlSelectedItemId !== null) { - setAutoSelectedEventId(null); - return; - } - - // While the feed is loading (e.g. a reload restoring `?item=` from the - // URL) the selected item simply hasn't arrived yet — don't clobber it. - if (isLoading || !feed) { - return; - } - - if (filteredItems.length === 0) { - setAutoSelectedEventId(null); - return; - } - - // Don't default-select before the width is measured: at width 0 - // isNarrowHomeViewport is false, so narrow Home would cold-load into detail. - if (homeInboxWidthPx === 0) { - return; - } - - // The event anchor is still valid if the conversation it belongs to is - // still present in the filtered list. A live representative-event change - // does NOT invalidate the anchor (the same conversationId is still there). - if ( - selectedConversationId !== null && - filteredItems.some( - (item) => item.conversationId === selectedConversationId, - ) - ) { - return; - } - - // A cold URL anchor is being resolved via getEventById — the user navigated - // to a specific event that is not yet in the inbox list. Do not overwrite - // selectedEventId; wait for cold recovery to commit before auto-selecting. - if (coldResolutionPending) { - return; - } - - setAutoSelectedEventId( - isNarrowHomeViewport ? null : (filteredItems[0]?.id ?? null), - ); - }, [ + useHomeInboxAutoSelection({ coldResolutionPending, - feed, filteredItems, + hasFeed: Boolean(feed), + hasPersonalSelection: + selectedDraftItem !== null || selectedReminder !== null, homeInboxWidthPx, isLoading, isMessagesMode, isNarrowHomeViewport, selectedConversationId, + setAutoSelectedEventId, urlSelectedItemId, - ]); + }); React.useEffect(() => { void selectedConversationId; @@ -604,6 +489,54 @@ export function HomeView({ setIsSendingReply(false); }, [selectedConversationId]); + const handleFilterChange = React.useCallback( + (nextFilter: InboxFilter) => { + const nextItems = inboxItems.filter( + (item) => + matchesInboxFilter(item, nextFilter, ownedAgentPubkeys) && + (!unreadOnly || + !effectiveDoneSet.has(item.id) || + item.conversationId === selectedConversationId), + ); + const selection = resolveInboxFilterSelection({ + isNarrow: isNarrowHomeViewport, + items: nextItems, + selectedConversationId, + }); + + setUnreadBoundary(null); + setSelectedDraftKey(null); + setSelectedReminderId(null); + setFilter(nextFilter); + + if ( + nextFilter === "reminders" || + nextFilter === "drafts" || + selection.preserveSelection + ) { + if (nextFilter === "reminders" || nextFilter === "drafts") { + setAutoSelectedEventId(null); + applyInboxSearchPatch({ item: null }); + } + return; + } + + applyInboxSearchPatch({ item: null }); + setAutoSelectedEventId(selection.autoSelectedEventId); + }, + [ + applyInboxSearchPatch, + effectiveDoneSet, + inboxItems, + isNarrowHomeViewport, + ownedAgentPubkeys, + selectedConversationId, + setSelectedDraftKey, + setSelectedReminderId, + unreadOnly, + ], + ); + if (isLoading && !feed) { return ; } @@ -629,63 +562,43 @@ export function HomeView({ ); } - const canReact = - selectedItem !== null && - selectedItem.item.channelId !== null && - availableChannelIds.has(selectedItem.item.channelId); - const canReply = - canReact && - selectedItem.item.kind !== 45001 && - selectedItem.item.kind !== 45003; - const disabledReplyReason = - canReply || !selectedItem - ? null - : selectedItem.item.channelId - ? availableChannelIds.has(selectedItem.item.channelId) - ? "This item does not support inline replies yet." - : "Open the linked channel to reply." - : "This inbox item does not have a reply target."; - const canDelete = - selectedItem !== null && - currentPubkey?.trim().toLowerCase() === - selectedItem.item.pubkey.trim().toLowerCase(); - const isSinglePanelDetailView = - isMessagesMode && - isNarrowHomeViewport && - selectedEventId !== null && - !isSinglePanelAuxiliaryView; - const isSinglePanelDraftDetailView = - isDrafts && - isNarrowHomeViewport && - selectedDraftItem !== null && - !isSinglePanelAuxiliaryView; - const showListPane = - !isSinglePanelDetailView && - !isSinglePanelDraftDetailView && - !isSinglePanelAuxiliaryView; - const showDetailPane = - !isSinglePanelAuxiliaryView && - ((isMessagesMode && (!isNarrowHomeViewport || isSinglePanelDetailView)) || - (isDrafts && (!isNarrowHomeViewport || isSinglePanelDraftDetailView))); - const auxiliaryPaneWidthPx = isSinglePanelAuxiliaryView - ? homeInboxWidthPx - : threadPanelWidthPx; - const maxEffectiveInboxListWidthPx = - homeInboxWidthPx > 0 - ? Math.max( - INBOX_COLUMN_MIN_WIDTH_PX, - homeInboxWidthPx - - INBOX_COLUMN_MIN_WIDTH_PX - - (hasAuxiliaryPane ? auxiliaryPaneWidthPx : 0), - ) - : undefined; - const effectiveInboxListWidthPx = - homeInboxWidthPx > 0 - ? Math.min( - inboxListWidthPx, - maxEffectiveInboxListWidthPx ?? inboxListWidthPx, - ) - : inboxListWidthPx; + const { canDelete, canReact, canReply, disabledReplyReason } = + getHomeMessageCapabilities( + selectedItem, + currentPubkey, + availableChannelIds, + ); + const detailMode = isDrafts + ? "drafts" + : isReminders + ? "reminders" + : selectedDraftItem + ? "drafts" + : selectedReminder + ? "reminders" + : "messages"; + const { + auxiliaryPaneWidthPx, + effectiveInboxListWidthPx, + isSinglePanelDetailView, + isSinglePanelDraftDetailView, + isSinglePanelReminderDetailView, + showDetailPane, + showListPane, + } = getHomePaneLayout({ + hasAuxiliaryPane, + homeWidthPx: homeInboxWidthPx, + inboxListWidthPx, + isDrafts: detailMode === "drafts", + isMessagesMode: detailMode === "messages", + isNarrow: isNarrowHomeViewport, + isReminders: detailMode === "reminders", + isSinglePanelAuxiliaryView, + selectedDraft: selectedDraftItem !== null, + selectedEvent: selectedEventId !== null, + selectedReminder: selectedReminder !== null, + threadPanelWidthPx, + }); return ( @@ -731,7 +644,7 @@ export function HomeView({ filter={filter} items={filteredItems} onDeleteDraft={handleDeleteDraft} - onFilterChange={setFilter} + onFilterChange={handleFilterChange} onMarkRead={markItemRead} onMarkUnread={markItemUnread} onOpenDirect={(item) => { @@ -758,14 +671,38 @@ export function HomeView({ }); }} onSelect={(itemId) => { + const item = findInboxItemByEventId(inboxItems, itemId); + setUnreadBoundary( + item && !effectiveDoneSet.has(item.id) + ? { + conversationId: item.conversationId, + eventId: item.id, + } + : null, + ); + setSelectedDraftKey(null); + setSelectedReminderId(null); handleUserSelectItem(itemId); markItemRead(itemId); }} - onSelectDraft={setSelectedDraftKey} + onSelectDraft={(draftKey) => { + setUnreadBoundary(null); + setSelectedReminderId(null); + handleUserSelectItem(null); + setSelectedDraftKey(draftKey); + }} + onSelectReminder={(reminderId) => { + setUnreadBoundary(null); + setSelectedDraftKey(null); + handleUserSelectItem(null); + setSelectedReminderId(reminderId); + }} onUnreadOnlyChange={setUnreadOnly} reminderPubkey={currentPubkey} + reminders={pendingReminders} selectedConversationId={selectedConversationId} selectedDraftKey={selectedDraftKey} + selectedReminderId={selectedReminderId} showRightDivider={showListPane && showDetailPane} unreadOnly={unreadOnly} /> @@ -794,7 +731,7 @@ export function HomeView({ - {showDetailPane && isMessagesMode ? ( + {showDetailPane && detailMode === "messages" ? ( { @@ -931,16 +870,20 @@ export function HomeView({ replies={selectedItemReplies} /> ) : null} - {showDetailPane && isDrafts ? ( - setSelectedDraftKey(null) - : undefined + : isSinglePanelReminderDetailView + ? () => setSelectedReminderId(null) + : undefined } - onDelete={handleDeleteDraft} + onDeleteDraft={handleDeleteDraft} + reminder={selectedReminder} /> ) : null} {profilePanelPubkey ? ( diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 5d6d98323f..9b6ff57e17 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -1,4 +1,12 @@ -import { ArrowLeft, Hash, Mail, MoreHorizontal, Trash2 } from "lucide-react"; +import { + AlertCircle, + ArrowLeft, + ExternalLink, + LoaderCircle, + Mail, + MoreHorizontal, + Trash2, +} from "lucide-react"; import * as React from "react"; import type { @@ -11,6 +19,7 @@ import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; +import { hasInboxThreadContext } from "@/features/home/lib/inboxViewHelpers"; import { type InboxDisplayMessage, InboxMessageRow, @@ -60,6 +69,7 @@ type InboxDetailPaneProps = { isDeletingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; + hasThreadContextLoadError?: boolean; isThreadContextLoading?: boolean; item: InboxItem | null; messages?: InboxContextMessage[]; @@ -75,6 +85,7 @@ type InboxDetailPaneProps = { * representative `item.id`. */ selectedEventId: string | null; + unreadBoundaryEventId?: string | null; /** * The default reply-parent event ID derived from the latched anchor's tags * in HomeView (`parentId ?? anchor.id`). Populated once the anchor is found @@ -95,7 +106,7 @@ type InboxDetailPaneProps = { content: string; mediaTags?: string[][]; mentionPubkeys: string[]; - parentEventId: string; + parentEventId: string | null; }) => Promise; onToggleReaction?: ( message: TimelineMessage, @@ -129,6 +140,7 @@ function InboxMessageDetailPane({ isDeletingMessage = false, isSendingReply = false, isSinglePanelView = false, + hasThreadContextLoadError = false, isThreadContextLoading = false, item, messages = [], @@ -138,6 +150,7 @@ function InboxMessageDetailPane({ contextChannelName = null, currentPubkey, selectedEventId, + unreadBoundaryEventId = null, latchedDefaultParentId = null, onBack, onDelete, @@ -161,6 +174,7 @@ function InboxMessageDetailPane({ // scroll centering) key on this. const conversationId = item?.conversationId ?? null; const selectedChannelId = item?.item.channelId ?? null; + const isDirectMessage = item?.item.channelType === "dm"; // Build the plain, non-virtualized timeline the shared hook anchors against. // Live arrivals rerun its layout compensation without changing the target. @@ -372,7 +386,8 @@ function InboxMessageDetailPane({ // (derived from the selected-event anchor at conversation entry), which does // not change when a live incoming message advances the representative item. const composerParentEventId = - replyTarget?.id ?? capturedDefaultParentId ?? item.id; + replyTarget?.id ?? + (isDirectMessage ? null : (capturedDefaultParentId ?? item.id)); const composerReplyTarget = replyTarget && replyTarget.id !== item.id ? { @@ -388,10 +403,27 @@ function InboxMessageDetailPane({ item.item.channelType === "forum" ? item.item.channelType : null; - const contextLabel = channelContextName ?? formatInboxTypeLabel(item); - const hasChannelContext = Boolean(channelContextName); + const isThreadContext = + !isDirectMessage && hasInboxThreadContext(item, messages); + const contextLabel = isThreadContext + ? isDirectMessage + ? `Thread with ${item.senderLabel}` + : channelContextName + ? `Thread in #${channelContextName}` + : "Thread" + : isDirectMessage + ? `DM with ${item.senderLabel}` + : channelContextName + ? `Message in #${channelContextName}` + : formatInboxTypeLabel(item); const contextChannelId = item.item.channelId; - const contextThreadRootId = getThreadReference(item.item.tags).rootId; + const sourceEventId = selectedEventId ?? item.id; + const contextThreadRootId = isThreadContext ? item.conversationId : null; + const openContextLabel = isThreadContext + ? "Open full thread" + : isDirectMessage + ? "Open conversation" + : "Open in channel"; const handleSelectReplyTarget = (message: InboxDisplayMessage) => { setReplyTargetId((currentReplyTargetId) => @@ -430,34 +462,31 @@ function InboxMessageDetailPane({ ) : null}
{canOpenChannel && contextChannelId ? ( - +

+ +

) : (

- {hasChannelContext ? ( - - ) : null} - + {contextLabel}

@@ -468,6 +497,30 @@ function InboxMessageDetailPane({
+ {canOpenChannel && contextChannelId ? ( + + + + + {openContextLabel} + + ) : null} {channel ? (
+ {isThreadContextLoading && displayMessages.length <= 1 ? ( +
+ + Loading surrounding context... +
+ ) : null} + {hasThreadContextLoadError ? ( +
+ + Some message context could not be loaded. +
+ ) : null} {displayMessages.map((message, index) => { - const isAfterSeparator = index === 1; + const hasUnreadBoundary = message.id === unreadBoundaryEventId; + const isAfterSeparator = index === 1 || hasUnreadBoundary; const previousMessage = displayMessages[index - 1]; const isContinuation = !isAfterSeparator && @@ -528,6 +600,7 @@ function InboxMessageDetailPane({ message={message} onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} + showUnreadBoundary={hasUnreadBoundary} /> ); })} @@ -561,17 +634,25 @@ function InboxMessageDetailPane({ />
setReplyTargetId(null) : undefined @@ -586,7 +667,9 @@ function InboxMessageDetailPane({ } placeholder={ canReply - ? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` + ? isDirectMessage + ? `Message ${item.senderLabel}` + : `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` : (disabledReplyReason ?? "Replies are not available for this item.") } diff --git a/desktop/src/features/home/ui/InboxFilterMenu.tsx b/desktop/src/features/home/ui/InboxFilterMenu.tsx new file mode 100644 index 0000000000..6fcc759731 --- /dev/null +++ b/desktop/src/features/home/ui/InboxFilterMenu.tsx @@ -0,0 +1,107 @@ +import { ChevronDown } from "lucide-react"; + +import type { InboxFilter } from "@/features/home/lib/inbox"; +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +const INBOX_FILTER_OPTIONS: Array<{ + label: string; + value: InboxFilter; +}> = [ + { value: "all", label: "All" }, + { value: "project", label: "Projects" }, + { value: "mention", label: "Mentions" }, + { value: "thread", label: "Threads" }, + { value: "needs_action", label: "Needs action" }, + { value: "agent_activity", label: "Agents" }, + { value: "reminders", label: "Reminders" }, + { value: "drafts", label: "Drafts" }, +]; + +const TRIGGER_CLASS = + "inline-flex h-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 data-[state=open]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 relative -ml-2 w-auto gap-1 px-2 text-sm font-medium text-foreground"; + +type InboxFilterMenuProps = { + activeDraftCount: number; + dueReminderCount: number; + filter: InboxFilter; + onFilterChange: (value: InboxFilter) => void; + reminderCount: number; +}; + +export function InboxFilterMenu({ + activeDraftCount, + dueReminderCount, + filter, + onFilterChange, + reminderCount, +}: InboxFilterMenuProps) { + const activeFilter = INBOX_FILTER_OPTIONS.find( + (option) => option.value === filter, + ); + const statusLabel = + dueReminderCount > 0 + ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` + : activeDraftCount > 0 + ? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}` + : null; + + return ( + + + + + + onFilterChange(value as InboxFilter)} + value={filter} + > + {INBOX_FILTER_OPTIONS.map((option) => ( +
+ {option.value === "reminders" ? ( + + ) : null} + + + {option.label} + + {option.value === "reminders" && reminderCount > 0 ? ( + + {reminderCount} + + ) : option.value === "drafts" && activeDraftCount > 0 ? ( + + {activeDraftCount} + + ) : null} + + + +
+ ))} +
+
+
+ ); +} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 603ebc2beb..20db0b5150 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,8 +1,9 @@ import { - ChevronDown, + Bell, Clock, Ellipsis, ExternalLink, + FileText, MailOpen, } from "lucide-react"; import * as React from "react"; @@ -13,12 +14,20 @@ import { type InboxItem, type InboxTypeLabel, } from "@/features/home/lib/inbox"; +import { buildInboxListRows } from "@/features/home/lib/inboxListRows"; +import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; import { DraftsPanel, + getDraftPreview, type DraftViewItem, } from "@/features/messages/ui/DraftsPanel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { isDue } from "@/features/reminders/lib/reminderFilters"; +import { + RemindersPanel, + useReminderSources, +} from "@/features/reminders/ui/RemindersPanel"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -34,13 +43,6 @@ import { MENTION_CHIP_BASE_CLASSES, MESSAGE_MARKDOWN_CLASS, } from "@/shared/ui/mentionChip"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Separator } from "@/shared/ui/separator"; import { Switch } from "@/shared/ui/switch"; @@ -48,24 +50,34 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; -const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ - { value: "all", label: "All" }, - { value: "project", label: "Projects" }, - { value: "mention", label: "Mentions" }, - { value: "thread", label: "Threads" }, - { value: "needs_action", label: "Needs Action" }, - { value: "activity", label: "Activity" }, - { value: "agent_activity", label: "Agents" }, - { value: "reminders", label: "Reminders" }, - { value: "drafts", label: "Drafts" }, -]; +const INBOX_EMPTY_STATE_TITLES: Record = { + all: "No activity yet", + project: "No project work found", + mention: "No mentions found", + thread: "No threads found", + needs_action: "Nothing needs action", + agent_activity: "No agent updates found", + reminders: "No reminders", + drafts: "No drafts", +}; + +const INBOX_UNREAD_EMPTY_STATE_TITLES: Record = { + all: "No unread activity", + project: "No unread project work", + mention: "No unread mentions", + thread: "No unread threads", + needs_action: "No unread items needing action", + agent_activity: "No unread agent updates", + reminders: "No unread reminders", + drafts: "No unread drafts", +}; const INBOX_HEADER_ICON_BUTTON_CLASS = "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 data-[state=open]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"; const INBOX_PANE_RIGHT_DIVIDER_CLASS = "after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-40 after:w-px after:bg-border/35 after:content-['']"; -function ActivityLabel({ +function InboxLabel({ isDone, isActionRequired, label, @@ -102,6 +114,76 @@ function ActivityLabel({ ); } +function formatReminderStatus(notBefore: number | undefined) { + if (notBefore === undefined) return "Pending"; + const secondsUntil = notBefore - Math.floor(Date.now() / 1_000); + if (secondsUntil <= 0) return "Reminder due"; + if (secondsUntil < 60) return "Reminder in less than a minute"; + if (secondsUntil < 3_600) { + return `Reminder in ${Math.floor(secondsUntil / 60)}m`; + } + if (secondsUntil < 86_400) { + return `Reminder in ${Math.floor(secondsUntil / 3_600)}h`; + } + return `Reminder in ${Math.floor(secondsUntil / 86_400)}d`; +} + +function PersonalItemRow({ + id, + kind, + location, + onClick, + preview, + selected, + status, +}: { + id: string; + kind: "drafts" | "reminders"; + location: InboxTypeLabel | null; + onClick: () => void; + preview: string; + selected: boolean; + status: string; +}) { + const isDraft = kind === "drafts"; + const Icon = isDraft ? FileText : Bell; + + return ( + + ); +} + type InboxListPaneProps = { activeReminderEventIds?: ReadonlySet; agentPubkeys?: ReadonlySet; @@ -118,12 +200,15 @@ type InboxListPaneProps = { onRemindLater: (item: InboxItem) => void; onSelect: (itemId: string) => void; onSelectDraft: (draftKey: string) => void; + onSelectReminder: (reminderId: string) => void; onUnreadOnlyChange: (checked: boolean) => void; selectedConversationId: string | null; selectedDraftKey: string | null; showRightDivider?: boolean; dueReminderCount: number; reminderPubkey?: string; + reminders: readonly Reminder[]; + selectedReminderId: string | null; unreadOnly: boolean; }; @@ -143,24 +228,42 @@ export function InboxListPane({ onRemindLater, onSelect, onSelectDraft, + onSelectReminder, onUnreadOnlyChange, selectedConversationId, selectedDraftKey, showRightDivider = false, dueReminderCount, reminderPubkey, + reminders, + selectedReminderId, unreadOnly, }: InboxListPaneProps) { - const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; - const inboxStatusLabel = - dueReminderCount > 0 - ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` - : activeDraftCount > 0 - ? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}` - : null; + const isMixedInboxView = filter === "all"; const scrollRef = React.useRef(null); + const inboxRows = React.useMemo( + () => + buildInboxListRows({ + drafts: unreadOnly || !isMixedInboxView ? [] : draftItems, + items, + reminders: unreadOnly + ? [] + : reminders.filter((reminder) => + isDue(reminder, Math.floor(Date.now() / 1_000)), + ), + }), + [draftItems, isMixedInboxView, items, reminders, unreadOnly], + ); + const visibleInboxRows = React.useMemo( + () => + isMixedInboxView + ? inboxRows + : inboxRows.filter((row) => row.kind === "inbox"), + [inboxRows, isMixedInboxView], + ); + const reminderSources = useReminderSources(reminders); const unreadVisibleItemCount = React.useMemo( () => items.reduce((count, item) => count + (doneSet.has(item.id) ? 0 : 1), 0), @@ -174,10 +277,14 @@ export function InboxListPane({ } }, [doneSet, items, onMarkRead]); - const renderItem = (item: InboxItem) => { + const renderItem = (item: InboxItem, dueReminder?: Reminder) => { const isSelected = item.conversationId === selectedConversationId; const isDone = doneSet.has(item.id); - const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false; + const hasActiveReminder = + dueReminder !== undefined || + [item.id, ...item.groupItems.map((groupItem) => groupItem.id)].some( + (eventId) => activeReminderEventIds?.has(eventId) ?? false, + ); const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = getInboxTypeLabel(item); const isSenderAgent = @@ -283,14 +390,28 @@ export function InboxListPane({ className="h-1.5 w-1.5 rounded-full bg-primary" /> ) : null} + {item.unreadCount > 1 ? ( + + {item.unreadCount} unread + + ) : null} {item.timestampLabel}
- + {dueReminder ? ( +
+ + Reminder due +
+ ) : null}
- Show unread + Show unread only
- - - - - - - onFilterChange(value as InboxFilter) - } - value={filter} - > - {FILTER_OPTIONS.map((option) => ( - - - {option.label} - {option.value === "reminders" && - dueReminderCount > 0 ? ( - - {dueReminderCount} - - ) : option.value === "drafts" && - activeDraftCount > 0 ? ( - - {activeDraftCount} - - ) : null} - - - ))} - - - +
@@ -533,7 +596,12 @@ export function InboxListPane({ data-testid="home-inbox-reminders" > {reminderPubkey ? ( - + ) : null}
) : isDrafts ? ( @@ -554,27 +622,89 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > - {items.length === 0 ? ( + {visibleInboxRows.length > 0 ? ( + row.key} + items={visibleInboxRows} + renderItem={(row) => { + if (row.kind === "inbox") { + return renderItem(row.item, row.dueReminder); + } + + if (row.kind === "reminder") { + const source = reminderSources.get(row.reminder.id); + return ( + { + onSelectReminder(row.reminder.id); + }} + preview={ + row.reminder.content.target?.preview || + row.reminder.content.note || + "Reminder" + } + selected={selectedReminderId === row.reminder.id} + status={formatReminderStatus(row.reminder.notBefore)} + /> + ); + } + + const { entry, source } = row.item; + return ( + { + onSelectDraft(entry.key); + }} + preview={getDraftPreview(entry.draft)} + selected={selectedDraftKey === entry.key} + status="Draft saved" + /> + ); + }} + scrollRef={scrollRef} + /> + ) : (

- {unreadOnly ? "No unread messages" : "No messages found"} + {unreadOnly + ? INBOX_UNREAD_EMPTY_STATE_TITLES[filter] + : INBOX_EMPTY_STATE_TITLES[filter]}

{unreadOnly - ? "Turn off the unread filter to see read messages." - : "Switch back to all mail to see more messages."} + ? "Turn off Show unread only to see read activity." + : filter === "all" + ? "New activity will appear here." + : "Switch back to All to see other activity."}

- ) : ( - item.id} - items={items} - renderItem={renderItem} - scrollRef={scrollRef} - /> )} )} diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index aaad2bcbd9..04deafb3ff 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -9,6 +9,7 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; +import { UnreadDivider } from "@/features/messages/ui/UnreadDivider"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; @@ -36,6 +37,7 @@ type InboxMessageRowProps = { emoji: string, remove: boolean, ) => Promise; + showUnreadBoundary?: boolean; }; export function InboxMessageRow({ @@ -48,6 +50,7 @@ export function InboxMessageRow({ message, onSelectReplyTarget, onToggleReaction, + showUnreadBoundary = false, }: InboxMessageRowProps) { const timelineMessage = React.useMemo( () => toTimelineMessage(message), @@ -89,6 +92,7 @@ export function InboxMessageRow({ return (
+ {showUnreadBoundary ? : null} {message.isSelected ? (