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/activityListRows.test.mjs b/desktop/src/features/home/lib/activityListRows.test.mjs new file mode 100644 index 0000000000..4836e47a21 --- /dev/null +++ b/desktop/src/features/home/lib/activityListRows.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildActivityListRows } from "./activityListRows.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("Activity All combines rows in latest-first order", () => { + const rows = buildActivityListRows({ + 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("Activity All excludes completed reminders and deleted-root drafts", () => { + const rows = buildActivityListRows({ + drafts: [draftItem("deleted", "2026-07-21T12:00:00.000Z", "deleted")], + items: [], + reminders: [reminder("done", 1_753_099_100, "done")], + }); + + assert.deepEqual(rows, []); +}); + +test("Activity conversation keys stay stable when the representative changes", () => { + const first = buildActivityListRows({ + drafts: [], + items: [inboxItem("reply-1", 1, "thread-root")], + reminders: [], + }); + const second = buildActivityListRows({ + 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 = buildActivityListRows({ + 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 = buildActivityListRows({ + 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/activityListRows.ts b/desktop/src/features/home/lib/activityListRows.ts new file mode 100644 index 0000000000..9ea538e38c --- /dev/null +++ b/desktop/src/features/home/lib/activityListRows.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 ActivityListRow = + | { + 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 buildActivityListRows({ + drafts, + items, + reminders, +}: { + drafts: readonly DraftViewItem[]; + items: readonly InboxItem[]; + reminders: readonly Reminder[]; +}): ActivityListRow[] { + const consumedReminderIds = new Set(); + const inboxRows = items.map((item): ActivityListRow => { + 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): ActivityListRow => ({ + key: `reminder:${reminder.id}`, + kind: "reminder", + reminder, + sortAt: reminder.notBefore ?? reminder.createdAt, + }), + ), + ...drafts + .filter((item) => item.rootStatus !== "deleted") + .map( + (item): ActivityListRow => ({ + 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/activitySelection.test.mjs b/desktop/src/features/home/lib/activitySelection.test.mjs new file mode 100644 index 0000000000..f93a15ac29 --- /dev/null +++ b/desktop/src/features/home/lib/activitySelection.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveActivityFilterSelection } from "./activitySelection.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( + resolveActivityFilterSelection({ + isNarrow: false, + items, + selectedConversationId: "second-conversation", + }), + { autoSelectedEventId: null, preserveSelection: true }, + ); +}); + +test("wide filter selection immediately selects the first valid row", () => { + assert.deepEqual( + resolveActivityFilterSelection({ + 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( + resolveActivityFilterSelection({ + isNarrow: true, + items, + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: null, preserveSelection: false }, + ); +}); + +test("empty filter selection clears detail at every width", () => { + assert.deepEqual( + resolveActivityFilterSelection({ + isNarrow: false, + items: [], + selectedConversationId: "filtered-out-conversation", + }), + { autoSelectedEventId: null, preserveSelection: false }, + ); +}); diff --git a/desktop/src/features/home/lib/activitySelection.ts b/desktop/src/features/home/lib/activitySelection.ts new file mode 100644 index 0000000000..0ba4d913b4 --- /dev/null +++ b/desktop/src/features/home/lib/activitySelection.ts @@ -0,0 +1,21 @@ +import type { InboxItem } from "@/features/home/lib/inbox"; + +export function resolveActivityFilterSelection({ + 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/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 b4c05ef809..97cfc782ea 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -21,7 +21,6 @@ export type InboxFilter = | "mention" | "thread" | "needs_action" - | "activity" | "agent_activity" | "reminders" | "drafts"; @@ -50,6 +49,7 @@ export type InboxItem = { senderLabel: string; subject: string; timestampLabel: string; + unreadCount: number; }; export type InboxTypeLabel = { @@ -207,6 +207,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); } @@ -299,9 +322,17 @@ function categoryPriority(category: FeedItemCategory) { } } -function getInboxThreadKey(item: FeedItem) { - const thread = getThreadReference(item.tags); - return thread.rootId ?? thread.parentId ?? item.id; +function getInboxThreadKey( + item: FeedItem, + channelById: ReadonlyMap, +) { + const channelType = resolveItemChannel(item, channelById).type; + return getInboxConversationId( + item.tags, + item.id, + item.channelId, + channelType, + ); } /** @@ -312,11 +343,31 @@ function getInboxThreadKey(item: FeedItem) { export function getInboxConversationId( tags: string[][], eventId: string, + channelId?: string | null, + channelType?: string, ): string { + if (channelType === "dm" && channelId) { + return `dm:${channelId}`; + } + const thread = getThreadReference(tags); return thread.rootId ?? thread.parentId ?? eventId; } +/** Finds the Activity 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) { const date = new Date(unixSeconds * 1_000); const now = new Date(); @@ -384,11 +435,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) { @@ -427,7 +487,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, @@ -451,7 +511,42 @@ export function buildInboxItems({ 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)); @@ -467,7 +562,6 @@ export function buildInboxItems({ item.tags, profiles, ); - const groupChannel = resolveGroupChannel(item, group.items, channelById); const channelLabel = groupChannel.name; const displayItem: FeedItem = { ...item, @@ -494,6 +588,7 @@ export function buildInboxItems({ senderLabel, subject, timestampLabel: formatInboxTimestamp(group.latestActivityAt), + unreadCount: unreadItems.length, }; }); } diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index e76b551239..eb0e7f70b1 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 { + filterActivityInboxItems, getContextMessageDepth, getReactionTargetId, + hasInboxThreadContext, isInboxThreadContextEvent, + matchesActivityAllView, matchesInboxFilter, toInboxContextMessage, toTimelineMessage, } from "./inboxViewHelpers.ts"; +test("Activity 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(filterActivityInboxItems(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,75 @@ test("matchesInboxFilter returns true for the 'all' filter regardless of categor assert.equal(matchesInboxFilter({ categories: ["mentions"] }, "all"), true); }); +test("Activity All excludes generic top-level channel traffic", () => { + const owned = new Set(["owned-agent"]); + assert.equal( + matchesActivityAllView( + { + categories: ["activity"], + item: { + channelType: "stream", + pubkey: "human", + tags: [["h", "channel"]], + }, + }, + owned, + ), + false, + ); +}); + +test("Activity 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: [] }, + }, + ]; + + for (const item of cases) { + assert.equal(matchesActivityAllView(item, owned), true); + } +}); + +test("Activity All excludes generic updates from agents the user does not own", () => { + assert.equal( + matchesActivityAllView( + { + 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 +156,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 e87671b9fa..f4c478c375 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 { getChannelIdFromTags, @@ -14,6 +15,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[][]) { @@ -21,6 +24,19 @@ function hasThreadReplyTags(tags: string[][]) { return thread.parentId !== null && !isBroadcastReply(tags); } +export function filterActivityInboxItems(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[]; @@ -28,9 +44,12 @@ export function matchesInboxFilter( item?: FeedItem; }, filter: InboxFilter, + ownedAgentPubkeys?: ReadonlySet, ) { if (filter === "all") { - return true; + return ownedAgentPubkeys + ? matchesActivityAllView(item, ownedAgentPubkeys) + : true; } if (filter === "thread") { @@ -39,9 +58,39 @@ 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 matchesActivityAllView( + 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.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/ActivityFilterMenu.tsx b/desktop/src/features/home/ui/ActivityFilterMenu.tsx new file mode 100644 index 0000000000..97e5e74103 --- /dev/null +++ b/desktop/src/features/home/ui/ActivityFilterMenu.tsx @@ -0,0 +1,106 @@ +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 ACTIVITY_FILTER_OPTIONS: Array<{ + label: string; + value: InboxFilter; +}> = [ + { value: "all", label: "All" }, + { value: "mention", label: "Mentions" }, + { value: "thread", label: "Threads" }, + { value: "needs_action", label: "Needs action" }, + { value: "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 ActivityFilterMenuProps = { + activeDraftCount: number; + dueReminderCount: number; + filter: InboxFilter; + onFilterChange: (value: InboxFilter) => void; + reminderCount: number; +}; + +export function ActivityFilterMenu({ + activeDraftCount, + dueReminderCount, + filter, + onFilterChange, + reminderCount, +}: ActivityFilterMenuProps) { + const activeFilter = ACTIVITY_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} + > + {ACTIVITY_FILTER_OPTIONS.map((option, index) => ( +
+ {index === 5 ? ( + + ) : null} + + + {option.label} + + {option.value === "reminders" && reminderCount > 0 ? ( + + {reminderCount} + + ) : option.value === "drafts" && activeDraftCount > 0 ? ( + + {activeDraftCount} + + ) : null} + + + +
+ ))} +
+
+
+ ); +} diff --git a/desktop/src/features/home/ui/HomePersonalActivityDetail.tsx b/desktop/src/features/home/ui/HomePersonalActivityDetail.tsx new file mode 100644 index 0000000000..2986476507 --- /dev/null +++ b/desktop/src/features/home/ui/HomePersonalActivityDetail.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 HomePersonalActivityDetailProps = { + currentPubkey?: string; + draftItem: DraftViewItem | null; + mode: "drafts" | "reminders"; + onBack?: () => void; + onDeleteDraft: (draftKey: string) => void; + reminder: Reminder | null; +}; + +export function HomePersonalActivityDetail({ + currentPubkey, + draftItem, + mode, + onBack, + onDeleteDraft, + reminder, +}: HomePersonalActivityDetailProps) { + 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 992702f431..069d14fee7 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, getInboxConversationId, } from "@/features/home/lib/inbox"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; +import { useOwnedAgentPubkeys } from "@/features/home/useOwnedAgentPubkeys"; import { - getReactionTargetId, + filterActivityInboxItems, matchesInboxFilter, - toInboxContextMessage, } from "@/features/home/lib/inboxViewHelpers"; +import { resolveActivityFilterSelection } from "@/features/home/lib/activitySelection"; 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 { useHomePersonalActivity } from "@/features/home/useHomePersonalActivity"; 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 { HomePersonalActivityDetail } from "@/features/home/ui/HomePersonalActivityDetail"; 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, + }, + } = useHomePersonalActivity({ + 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 filterActivityInboxItems(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], ); @@ -413,7 +405,12 @@ export function HomeView({ // correct row selected (by conversationId) even after the anchor event has // been displaced from groupItems by a newer representative. const latchedConversationId = activeLatchedItem - ? getInboxConversationId(activeLatchedItem.tags, activeLatchedItem.id) + ? getInboxConversationId( + activeLatchedItem.tags, + activeLatchedItem.id, + activeLatchedItem.channelId, + activeLatchedItem.channelType, + ) : null; const selectedConversationId = selectedItemFromAll?.conversationId ?? latchedConversationId; @@ -421,7 +418,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 +427,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 +473,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 +494,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 = resolveActivityFilterSelection({ + 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 +567,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 +649,7 @@ export function HomeView({ filter={filter} items={filteredItems} onDeleteDraft={handleDeleteDraft} - onFilterChange={setFilter} + onFilterChange={handleFilterChange} onMarkRead={markItemRead} onMarkUnread={markItemUnread} onOpenDirect={(item) => { @@ -758,14 +676,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 +736,7 @@ export function HomeView({ - {showDetailPane && isMessagesMode ? ( + {showDetailPane && detailMode === "messages" ? ( { @@ -931,16 +875,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 23a1748225..2fa020e586 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 { @@ -9,6 +17,7 @@ import type { 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, @@ -58,6 +67,7 @@ type InboxDetailPaneProps = { isDeletingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; + hasThreadContextLoadError?: boolean; isThreadContextLoading?: boolean; item: InboxItem | null; messages?: InboxContextMessage[]; @@ -73,6 +83,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 @@ -93,7 +104,7 @@ type InboxDetailPaneProps = { content: string; mediaTags?: string[][]; mentionPubkeys: string[]; - parentEventId: string; + parentEventId: string | null; }) => Promise; onToggleReaction?: ( message: TimelineMessage, @@ -111,6 +122,7 @@ export function InboxDetailPane({ isDeletingMessage = false, isSendingReply = false, isSinglePanelView = false, + hasThreadContextLoadError = false, isThreadContextLoading = false, item, messages = [], @@ -120,6 +132,7 @@ export function InboxDetailPane({ contextChannelName = null, currentPubkey, selectedEventId, + unreadBoundaryEventId = null, latchedDefaultParentId = null, onBack, onDelete, @@ -143,6 +156,7 @@ export function InboxDetailPane({ // 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. @@ -354,7 +368,8 @@ export function InboxDetailPane({ // (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 ? { @@ -370,10 +385,27 @@ export function InboxDetailPane({ 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) => @@ -412,34 +444,31 @@ export function InboxDetailPane({ ) : null}
{canOpenChannel && contextChannelId ? ( - +

+ +

) : (

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

@@ -450,6 +479,30 @@ export function InboxDetailPane({
+ {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 && @@ -510,6 +582,7 @@ export function InboxDetailPane({ message={message} onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} + showUnreadBoundary={hasUnreadBoundary} /> ); })} @@ -543,17 +616,25 @@ export function InboxDetailPane({ />
setReplyTargetId(null) : undefined @@ -568,7 +649,9 @@ export function InboxDetailPane({ } 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/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index b92008e7a2..b0020b16c0 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 { buildActivityListRows } from "@/features/home/lib/activityListRows"; +import { ActivityFilterMenu } from "@/features/home/ui/ActivityFilterMenu"; 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,16 +50,25 @@ 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: "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 ACTIVITY_EMPTY_STATE_TITLES: Record = { + all: "No activity yet", + 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 ACTIVITY_UNREAD_EMPTY_STATE_TITLES: Record = { + all: "No unread activity", + 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"; @@ -101,6 +112,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; @@ -117,12 +198,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; }; @@ -142,24 +226,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 isMixedActivityView = filter === "all"; const scrollRef = React.useRef(null); + const activityRows = React.useMemo( + () => + buildActivityListRows({ + drafts: unreadOnly || !isMixedActivityView ? [] : draftItems, + items, + reminders: unreadOnly + ? [] + : reminders.filter((reminder) => + isDue(reminder, Math.floor(Date.now() / 1_000)), + ), + }), + [draftItems, isMixedActivityView, items, reminders, unreadOnly], + ); + const visibleActivityRows = React.useMemo( + () => + isMixedActivityView + ? activityRows + : activityRows.filter((row) => row.kind === "inbox"), + [activityRows, isMixedActivityView], + ); + const reminderSources = useReminderSources(reminders); const unreadVisibleItemCount = React.useMemo( () => items.reduce((count, item) => count + (doneSet.has(item.id) ? 0 : 1), 0), @@ -173,10 +275,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 = @@ -282,6 +388,11 @@ export function InboxListPane({ className="h-1.5 w-1.5 rounded-full bg-primary" /> ) : null} + {item.unreadCount > 1 ? ( + + {item.unreadCount} unread + + ) : null} {item.timestampLabel}
@@ -290,6 +401,15 @@ export function InboxListPane({ isDone={isDone} label={typeLabel} /> + {dueReminder ? ( +
+ + Reminder due +
+ ) : null}
- - - - - - - 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} - - - ))} - - - +
@@ -532,7 +594,12 @@ export function InboxListPane({ data-testid="home-inbox-reminders" > {reminderPubkey ? ( - + ) : null}
) : isDrafts ? ( @@ -553,27 +620,89 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > - {items.length === 0 ? ( + {visibleActivityRows.length > 0 ? ( + row.key} + items={visibleActivityRows} + 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 + ? ACTIVITY_UNREAD_EMPTY_STATE_TITLES[filter] + : ACTIVITY_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 ? (