From 781ca74bb547490ac9bce2867fea185daf10daf2 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Tue, 21 Jul 2026 16:39:45 -0400 Subject: [PATCH 1/6] feat(desktop): gate Activity refactor as experiment --- desktop/playwright.config.ts | 1 + desktop/src/app/AppShell.tsx | 5 + .../app/useThreadActivityFeedItems.test.mjs | 21 ++ desktop/src/app/useThreadActivityFeedItems.ts | 21 +- .../agents/knownAgentPubkeys.test.mjs | 28 +- .../src/features/agents/knownAgentPubkeys.ts | 28 ++ .../channels/useLiveChannelUpdates.ts | 17 +- .../home/lib/activityListRows.test.mjs | 45 +++ .../src/features/home/lib/activityListRows.ts | 75 ++++ .../home/lib/homeMessageCapabilities.ts | 31 ++ .../src/features/home/lib/homePaneLayout.ts | 76 ++++ .../home/lib/inboxViewHelpers.test.mjs | 36 ++ .../src/features/home/lib/inboxViewHelpers.ts | 20 ++ .../home/ui/HomePersonalActivityDetail.tsx | 45 +++ desktop/src/features/home/ui/HomeScreen.tsx | 3 + desktop/src/features/home/ui/HomeView.tsx | 185 +++++----- .../src/features/home/ui/InboxListPane.tsx | 247 ++++++++++++- .../features/home/useActivityInboxFilter.ts | 13 + .../features/home/useHomePersonalActivity.ts | 77 ++++ .../src/features/home/useOwnedAgentPubkeys.ts | 17 + .../features/reminders/ui/RemindersPanel.tsx | 337 +++++++++++++++--- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 16 +- desktop/tests/e2e/activity-experiment.spec.ts | 65 ++++ desktop/tests/helpers/bridge.ts | 14 +- preview-features.json | 26 +- 25 files changed, 1257 insertions(+), 192 deletions(-) create mode 100644 desktop/src/features/home/lib/activityListRows.test.mjs create mode 100644 desktop/src/features/home/lib/activityListRows.ts 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/ui/HomePersonalActivityDetail.tsx create mode 100644 desktop/src/features/home/useActivityInboxFilter.ts create mode 100644 desktop/src/features/home/useHomePersonalActivity.ts create mode 100644 desktop/src/features/home/useOwnedAgentPubkeys.ts create mode 100644 desktop/tests/e2e/activity-experiment.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5046b29688..eb5c845606 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/activity-experiment.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa9348..e2af5c0c33 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,6 +96,8 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; +import { useFeatureEnabled } from "@/shared/features"; + const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -105,6 +107,7 @@ export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); + const activityEnabled = useFeatureEnabled("activity"); const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; @@ -339,6 +342,7 @@ export function AppShell() { muteThread, unmuteThread, } = useUnreadChannels(sidebarChannels, activeChannel, { + includeDmHomeActivity: activityEnabled, pubkey: identityQuery.data?.pubkey, relayClient, relayUrl: communitiesHook.activeCommunity?.relayUrl, @@ -399,6 +403,7 @@ export function AppShell() { threadActivityItems, mutedRootIds, channels, + activityEnabled, ); // Badge count consumes the shared NIP-RS read-state from useUnreadChannels. diff --git a/desktop/src/app/useThreadActivityFeedItems.test.mjs b/desktop/src/app/useThreadActivityFeedItems.test.mjs index 6c017028ca..12e0d888be 100644 --- a/desktop/src/app/useThreadActivityFeedItems.test.mjs +++ b/desktop/src/app/useThreadActivityFeedItems.test.mjs @@ -113,3 +113,24 @@ test("channel-presence fence applies before mute filter — unknown channel + mu assert.deepEqual(items, []); }); + +test("top-level DM activity is gated by the Activity experiment", () => { + 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, + ), + [], + ); + assert.deepEqual( + buildThreadActivityFeedItems([dmItem], new Set(), channels, true).map( + (item) => item.id, + ), + ["agent-dm-reply"], + ); +}); diff --git a/desktop/src/app/useThreadActivityFeedItems.ts b/desktop/src/app/useThreadActivityFeedItems.ts index af0c50ce48..29e7196702 100644 --- a/desktop/src/app/useThreadActivityFeedItems.ts +++ b/desktop/src/app/useThreadActivityFeedItems.ts @@ -8,6 +8,7 @@ export function buildThreadActivityFeedItems( threadActivityItems: ThreadActivityItem[], mutedRootIds: ReadonlySet, channels: Channel[], + includeDmHomeActivity = false, ): FeedItem[] { const channelById = new Map(channels.map((channel) => [channel.id, channel])); @@ -17,7 +18,15 @@ 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; + if ( + channel.channelType === "dm" && + getThreadReference(item.tags).parentId === null && + !includeDmHomeActivity + ) { + return false; + } const rootId = getThreadReference(item.tags).rootId; return !rootId || !mutedRootIds.has(rootId); }) @@ -42,6 +51,7 @@ export function useThreadActivityFeedItems( threadActivityItems: ThreadActivityItem[], mutedRootIds: ReadonlySet, channels: Channel[], + includeDmHomeActivity = false, ): FeedItem[] { const mutedRootIdsKey = [...mutedRootIds].sort().join("\0"); @@ -52,6 +62,13 @@ export function useThreadActivityFeedItems( threadActivityItems, mutedRootIds, channels, + includeDmHomeActivity, ); - }, [channels, mutedRootIds, mutedRootIdsKey, threadActivityItems]); + }, [ + channels, + includeDmHomeActivity, + mutedRootIds, + mutedRootIdsKey, + threadActivityItems, + ]); } 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..5247784732 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -25,6 +25,7 @@ import { refreshChannelsWhenIdle } from "./refreshChannelsWhenIdle"; export type UseLiveChannelUpdatesOptions = { currentPubkey?: string; + includeDmHomeActivity?: boolean; /** * When true, DM notifications also fire for the channel the user is * currently viewing (normally suppressed). @@ -88,6 +89,14 @@ export function isChannelUnreadTriggerKind(kind: number, isDmChannel: boolean) { : UNREAD_TRIGGER_KINDS.has(kind); } +export function isHomeActivityEvent( + isDmChannel: boolean, + isThreadedReply: boolean, + includeDmHomeActivity = false, +) { + return isThreadedReply || (isDmChannel && includeDmHomeActivity); +} + export function withChannelTagFallback( event: RelayEvent, channelId: string, @@ -278,7 +287,13 @@ export function useLiveChannelUpdates( } } else { options.onChannelMessage?.(channelId, event); - if (isThreadedReply) { + if ( + isHomeActivityEvent( + isDmChannel, + isThreadedReply, + options.includeDmHomeActivity, + ) + ) { 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..3044454225 --- /dev/null +++ b/desktop/src/features/home/lib/activityListRows.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildActivityListRows } from "./activityListRows.ts"; + +function inboxItem(id, latestActivityAt) { + return { id, latestActivityAt }; +} + +function draftItem(key, updatedAt, rootStatus = "available") { + return { + entry: { + key, + draft: { createdAt: updatedAt, updatedAt }, + }, + rootStatus, + }; +} + +function reminder(id, createdAt, status = "pending") { + return { id, createdAt, content: { status } }; +} + +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, []); +}); diff --git a/desktop/src/features/home/lib/activityListRows.ts b/desktop/src/features/home/lib/activityListRows.ts new file mode 100644 index 0000000000..951d07bdd0 --- /dev/null +++ b/desktop/src/features/home/lib/activityListRows.ts @@ -0,0 +1,75 @@ +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; + 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[] { + return [ + ...items.map( + (item): ActivityListRow => ({ + key: `inbox:${item.id}`, + kind: "inbox", + item, + sortAt: item.latestActivityAt, + }), + ), + ...reminders + .filter((reminder) => reminder.content.status === "pending") + .map( + (reminder): ActivityListRow => ({ + key: `reminder:${reminder.id}`, + kind: "reminder", + reminder, + sortAt: 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/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..ac409ea103 --- /dev/null +++ b/desktop/src/features/home/lib/homePaneLayout.ts @@ -0,0 +1,76 @@ +import { INBOX_COLUMN_MIN_WIDTH_PX } from "@/features/home/useResizableInboxListWidth"; + +type HomePaneLayoutOptions = { + activityEnabled: boolean; + 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.activityEnabled && + 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.activityEnabled && + 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/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index e76b551239..31532c39ad 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { formatTimelineMessages } from "../../messages/lib/formatTimelineMessages.ts"; import { getConfigNudgeAuthorPubkey } from "../../messages/ui/configNudgeAuthPubkey.ts"; import { + filterActivityInboxItems, getContextMessageDepth, getReactionTargetId, isInboxThreadContextEvent, @@ -12,6 +13,15 @@ import { 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.equal(filterActivityInboxItems(items, false), items); + assert.deepEqual(filterActivityInboxItems(items, true), [message]); +}); + // --- matchesInboxFilter --- test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => { @@ -34,6 +44,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..112937385a 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,15 @@ function hasThreadReplyTags(tags: string[][]) { return thread.parentId !== null && !isBroadcastReply(tags); } +export function filterActivityInboxItems( + items: InboxItem[], + activityEnabled: boolean, +) { + return activityEnabled + ? items.filter((item) => item.item.kind !== KIND_REMINDER) + : items; +} + export function matchesInboxFilter( item: { categories: readonly string[]; @@ -28,6 +40,7 @@ export function matchesInboxFilter( item?: FeedItem; }, filter: InboxFilter, + ownedAgentPubkeys?: ReadonlySet, ) { if (filter === "all") { return true; @@ -39,6 +52,13 @@ 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); } 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/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index b6512816e3..fd7fc4ef90 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -4,6 +4,7 @@ import { useAppShell } from "@/app/AppShellContext"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; import type { HomeFeedResponse } from "@/shared/api/types"; +import { useFeatureEnabled } from "@/shared/features"; import { isRelayUnreachableError, RELAY_UNREACHABLE_MESSAGE, @@ -25,6 +26,7 @@ export function HomeScreen({ onOpenContext, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); + const activityEnabled = useFeatureEnabled("activity"); const { threadActivityFeedItems } = useAppShell(); const augmentedFeed = React.useMemo((): HomeFeedResponse | undefined => { @@ -48,6 +50,7 @@ export function HomeScreen({ return (
0 && homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; - const [filter, setFilter] = React.useState("all"); + const [filter, setFilter] = useActivityInboxFilter(activityEnabled); const [unreadOnly, setUnreadOnly] = React.useState(false); // Explicit selections are mirrored to the URL (`?item=`), so back/forward // restores the detail pane each history entry was showing and reloads @@ -142,18 +143,28 @@ 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 { - 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({ + activityEnabled, + currentPubkey, isDrafts, isNarrowHomeViewport, + isReminders, viewportWidthPx: homeInboxWidthPx, }); // `?item=` is Messages-mode-only machinery: a reminder never enters the @@ -342,6 +353,11 @@ export function HomeView({ enabled: feedProfilePubkeys.length > 0, }); const feedProfiles = feedProfilesQuery.data?.profiles; + const ownedAgentPubkeys = useOwnedAgentPubkeys( + activityEnabled, + feedProfiles, + currentPubkey, + ); const feedOwnerPubkeys = React.useMemo( () => [ ...new Set( @@ -370,16 +386,15 @@ export function HomeView({ return pubkeys; }, [feedProfiles, communityAgentPubkeys]); - const inboxItems = React.useMemo( - () => - buildInboxItems({ - channels, - currentPubkey, - feed, - profiles: feedProfiles, - }), - [channels, currentPubkey, feed, feedProfiles], - ); + const inboxItems = React.useMemo(() => { + const items = buildInboxItems({ + channels, + currentPubkey, + feed, + profiles: feedProfiles, + }); + return filterActivityInboxItems(items, activityEnabled); + }, [activityEnabled, channels, currentPubkey, feed, feedProfiles]); const { effectiveDoneSet, markItemRead, markItemUnread } = useHomeInboxReadState({ items: inboxItems, @@ -421,15 +436,21 @@ export function HomeView({ const filteredItems = React.useMemo(() => { return inboxItems.filter( (item) => - matchesInboxFilter(item, filter) && + matchesInboxFilter( + item, + filter, + activityEnabled ? ownedAgentPubkeys : undefined, + ) && (!unreadOnly || !effectiveDoneSet.has(item.id) || item.conversationId === selectedConversationId), ); }, [ effectiveDoneSet, + activityEnabled, filter, inboxItems, + ownedAgentPubkeys, selectedConversationId, unreadOnly, ]); @@ -629,63 +650,35 @@ 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 { + auxiliaryPaneWidthPx, + effectiveInboxListWidthPx, + isSinglePanelDetailView, + isSinglePanelDraftDetailView, + isSinglePanelReminderDetailView, + showDetailPane, + showListPane, + } = getHomePaneLayout({ + activityEnabled, + hasAuxiliaryPane, + homeWidthPx: homeInboxWidthPx, + inboxListWidthPx, + isDrafts, + isMessagesMode, + isNarrow: isNarrowHomeViewport, + isReminders, + isSinglePanelAuxiliaryView, + selectedDraft: selectedDraftItem !== null, + selectedEvent: selectedEventId !== null, + selectedReminder: selectedReminder !== null, + threadPanelWidthPx, + }); return ( @@ -722,6 +715,7 @@ export function HomeView({ {showListPane ? ( @@ -931,16 +928,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/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index b92008e7a2..389a47d861 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,8 +1,10 @@ import { + Bell, ChevronDown, Clock, Ellipsis, ExternalLink, + FileText, MailOpen, } from "lucide-react"; import * as React from "react"; @@ -13,12 +15,18 @@ import { type InboxItem, type InboxTypeLabel, } from "@/features/home/lib/inbox"; +import { buildActivityListRows } from "@/features/home/lib/activityListRows"; 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 { + 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"; @@ -59,6 +67,41 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "drafts", label: "Drafts" }, ]; +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 ACTIVITY_EMPTY_STATE_TITLES: Record = { + all: "No activity yet", + mention: "No mentions found", + thread: "No threads found", + needs_action: "Nothing needs action", + activity: "No activity found", + 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", + activity: "No unread activity", + 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 = @@ -101,7 +144,72 @@ 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, + status, +}: { + id: string; + kind: "drafts" | "reminders"; + location: InboxTypeLabel | null; + onClick: () => void; + preview: string; + status: string; +}) { + const isDraft = kind === "drafts"; + const Icon = isDraft ? FileText : Bell; + + return ( + + ); +} + type InboxListPaneProps = { + activityEnabled: boolean; activeReminderEventIds?: ReadonlySet; agentPubkeys?: ReadonlySet; activeDraftCount: number; @@ -117,16 +225,20 @@ 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; }; export function InboxListPane({ + activityEnabled, activeReminderEventIds, agentPubkeys, activeDraftCount, @@ -142,15 +254,22 @@ 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 filterOptions = activityEnabled + ? ACTIVITY_FILTER_OPTIONS + : FILTER_OPTIONS; + const activeFilter = filterOptions.find((option) => option.value === filter); + const viewLabel = activityEnabled ? "activity" : "inbox"; const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; const inboxStatusLabel = @@ -160,6 +279,16 @@ export function InboxListPane({ ? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}` : null; const scrollRef = React.useRef(null); + const activityRows = React.useMemo( + () => + buildActivityListRows({ + drafts: unreadOnly ? [] : draftItems, + items, + reminders: unreadOnly ? [] : reminders, + }), + [draftItems, items, reminders, unreadOnly], + ); + const reminderSources = useReminderSources(reminders); const unreadVisibleItemCount = React.useMemo( () => items.reduce((count, item) => count + (doneSet.has(item.id) ? 0 : 1), 0), @@ -408,7 +537,7 @@ export function InboxListPane({
) : isDrafts ? ( @@ -553,16 +692,92 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > - {items.length === 0 ? ( + {activityEnabled && filter === "all" && activityRows.length > 0 ? ( + row.key} + items={activityRows} + renderItem={(row) => { + if (row.kind === "inbox") return renderItem(row.item); + + if (row.kind === "reminder") { + const source = reminderSources.get(row.reminder.id); + return ( + { + onSelectReminder(row.reminder.id); + onFilterChange("reminders"); + }} + preview={ + row.reminder.content.target?.preview || + row.reminder.content.note || + "Reminder" + } + status={formatReminderStatus(row.reminder.notBefore)} + /> + ); + } + + const { entry, source } = row.item; + return ( + { + onSelectDraft(entry.key); + onFilterChange("drafts"); + }} + preview={getDraftPreview(entry.draft)} + status="Draft saved" + /> + ); + }} + scrollRef={scrollRef} + /> + ) : items.length === 0 ? (

- {unreadOnly ? "No unread messages" : "No messages found"} + {activityEnabled + ? unreadOnly + ? ACTIVITY_UNREAD_EMPTY_STATE_TITLES[filter] + : ACTIVITY_EMPTY_STATE_TITLES[filter] + : unreadOnly + ? "No unread messages" + : "No messages found"}

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

diff --git a/desktop/src/features/home/useActivityInboxFilter.ts b/desktop/src/features/home/useActivityInboxFilter.ts new file mode 100644 index 0000000000..4d485fdd02 --- /dev/null +++ b/desktop/src/features/home/useActivityInboxFilter.ts @@ -0,0 +1,13 @@ +import * as React from "react"; + +import type { InboxFilter } from "@/features/home/lib/inbox"; + +export function useActivityInboxFilter(activityEnabled: boolean) { + const [filter, setFilter] = React.useState("all"); + + React.useEffect(() => { + if (activityEnabled && filter === "activity") setFilter("all"); + }, [activityEnabled, filter]); + + return [filter, setFilter] as const; +} diff --git a/desktop/src/features/home/useHomePersonalActivity.ts b/desktop/src/features/home/useHomePersonalActivity.ts new file mode 100644 index 0000000000..87ff7a86c5 --- /dev/null +++ b/desktop/src/features/home/useHomePersonalActivity.ts @@ -0,0 +1,77 @@ +import * as React from "react"; + +import { useHomeDrafts } from "@/features/home/useHomeDrafts"; +import { + countDueReminders, + useRemindersQuery, +} from "@/features/reminders/hooks"; +import { groupReminders } from "@/features/reminders/lib/reminderFilters"; + +type UseHomePersonalActivityOptions = { + activityEnabled: boolean; + currentPubkey?: string; + isDrafts: boolean; + isNarrowHomeViewport: boolean; + isReminders: boolean; + viewportWidthPx: number; +}; + +export function useHomePersonalActivity({ + activityEnabled, + currentPubkey, + isDrafts, + isNarrowHomeViewport, + isReminders, + viewportWidthPx, +}: UseHomePersonalActivityOptions) { + const remindersQuery = useRemindersQuery(currentPubkey); + const dueReminderCount = countDueReminders(remindersQuery.data ?? []); + const pendingReminders = React.useMemo( + () => + groupReminders(remindersQuery.data ?? []).flatMap( + (group) => group.reminders, + ), + [remindersQuery.data], + ); + const [selectedReminderId, selectReminder] = React.useState( + null, + ); + const selectedReminder = + pendingReminders.find((reminder) => reminder.id === selectedReminderId) ?? + null; + + React.useEffect(() => { + if (!activityEnabled || !isReminders) { + selectReminder(null); + return; + } + if (viewportWidthPx === 0 || selectedReminder !== null) return; + selectReminder( + isNarrowHomeViewport ? null : (pendingReminders[0]?.id ?? null), + ); + }, [ + activityEnabled, + isNarrowHomeViewport, + isReminders, + pendingReminders, + selectedReminder, + viewportWidthPx, + ]); + + const drafts = useHomeDrafts({ + isDrafts, + isNarrowHomeViewport, + viewportWidthPx, + }); + + return { + drafts, + dueReminderCount, + pendingReminders, + reminders: { + selectedId: selectedReminderId, + selectedItem: selectedReminder, + select: selectReminder, + }, + }; +} diff --git a/desktop/src/features/home/useOwnedAgentPubkeys.ts b/desktop/src/features/home/useOwnedAgentPubkeys.ts new file mode 100644 index 0000000000..8fe614156d --- /dev/null +++ b/desktop/src/features/home/useOwnedAgentPubkeys.ts @@ -0,0 +1,17 @@ +import * as React from "react"; + +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { mergeOwnedAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; + +export function useOwnedAgentPubkeys( + enabled: boolean, + profiles: UserProfileLookup | undefined, + currentPubkey: string | undefined, +) { + const managedAgents = useManagedAgentsQuery({ enabled }).data; + return React.useMemo( + () => mergeOwnedAgentPubkeys(managedAgents, profiles, currentPubkey), + [currentPubkey, managedAgents, profiles], + ); +} diff --git a/desktop/src/features/reminders/ui/RemindersPanel.tsx b/desktop/src/features/reminders/ui/RemindersPanel.tsx index 651768a251..4287ba6045 100644 --- a/desktop/src/features/reminders/ui/RemindersPanel.tsx +++ b/desktop/src/features/reminders/ui/RemindersPanel.tsx @@ -1,4 +1,4 @@ -import { Bell, Check, Clock, X } from "lucide-react"; +import { ArrowLeft, Bell, Check, Clock, ExternalLink, X } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -22,6 +22,9 @@ import type { Reminder } from "@/features/reminders/lib/reminderTypes"; import { SnoozeMenu } from "@/features/reminders/ui/SnoozeMenu"; import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels"; import { useIdentityQuery } from "@/shared/api/hooks"; +import type { Channel } from "@/shared/api/types"; +import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; +import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -29,12 +32,56 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; const UNKNOWN_CHANNEL_LABEL = "Unknown channel"; /** Author identity + source channel resolved for a reminder's target. */ -type ReminderSource = { +export type ReminderSource = { authorLabel: string; avatarUrl: string | null; + channel: Channel | null; channelLabel: string; }; +export function useReminderSources(reminders: readonly Reminder[]) { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + const channelsQuery = useChannelsQuery(); + const channels = channelsQuery.data; + const authorPubkeys = React.useMemo( + () => + reminders + .map((reminder) => reminder.content.target?.authorPubkey) + .filter((authorPubkey): authorPubkey is string => !!authorPubkey), + [reminders], + ); + const usersBatchQuery = useUsersBatchQuery(authorPubkeys); + const profiles: UserProfileLookup | undefined = + usersBatchQuery.data?.profiles; + + return React.useMemo(() => { + const channelsById = new Map( + (channels ?? []).map((channel) => [channel.id, channel]), + ); + const map = new Map(); + for (const reminder of reminders) { + const target = reminder.content.target; + if (!hasNavigableTarget(target)) continue; + const channel = channelsById.get(target.channelId); + map.set(reminder.id, { + authorLabel: resolveUserLabel({ + currentPubkey, + profiles, + pubkey: target.authorPubkey, + }), + avatarUrl: + profiles?.[normalizePubkey(target.authorPubkey)]?.avatarUrl ?? null, + channel: channel ?? null, + channelLabel: channel + ? resolveChannelDisplayLabel(channel, currentPubkey, profiles) + : UNKNOWN_CHANNEL_LABEL, + }); + } + return map; + }, [channels, currentPubkey, profiles, reminders]); +} + function formatRelativeTime(timestamp: number): string { const now = Math.floor(Date.now() / 1_000); const diff = timestamp - now; @@ -54,15 +101,21 @@ function formatRelativeTime(timestamp: number): string { } function ReminderRow({ + isSelected = false, + presentation = "card", reminder, pubkey, source, onNavigate, + onSelect, }: { + isSelected?: boolean; + presentation?: "activity-list" | "card"; reminder: Reminder; pubkey: string; source: ReminderSource | null; onNavigate: (reminder: Reminder) => void; + onSelect?: (reminder: Reminder) => void; }) { const { complete, snooze, cancel } = useReminderMutations(pubkey); const isDone = reminder.content.status === "done"; @@ -97,13 +150,31 @@ function ReminderRow({ !isDone && reminder.notBefore ? reminder.notBefore <= Math.floor(Date.now() / 1_000) : false; + const isActivityList = presentation === "activity-list"; return ( -
+
+ {isActivityList ? ( + + + + ) : null} - {isDone ? null : ( + {isDone || isActivityList ? null : (
+ ) : null} + Reminder +
+ + +
+
+ {source ? ( +
+ + + {source.authorLabel} + + in + + {source.channel?.channelType === "dm" ? "" : "#"} + {source.channelLabel} + +
+ ) : null} + +

+ {preview} +

+ {reminder.content.target && reminder.content.note ? ( +
+

+ Note +

+

+ {reminder.content.note} +

+
+ ) : null} + + {reminder.notBefore ? ( +
+ + {formatRelativeTime(reminder.notBefore)} +
+ ) : null} + +
+ + {isDone ? null : ( + <> + + + snooze.mutate( + { reminder, notBefore }, + { + onSuccess: () => toast.success("Reminder snoozed"), + onError: () => toast.error("Failed to snooze reminder"), + }, + ) + } + /> + + + )} +
+
+
+ + ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 50298a92d1..e18a6bf333 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,7 +1,7 @@ -import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; +import { Activity, Bell, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; -import { FeatureGate } from "@/shared/features"; +import { FeatureGate, useFeatureEnabled } from "@/shared/features"; import type { Channel, SearchHit } from "@/shared/api/types"; import { SidebarHeader, @@ -89,9 +89,13 @@ export function AppSidebarPrimaryMenu({ onSelectWorkflows, selectedView, }: AppSidebarPrimaryMenuProps) { + const activityEnabled = useFeatureEnabled("activity"); + const HomeIcon = activityEnabled ? Bell : Inbox; + const homeLabel = activityEnabled ? "Activity" : "Inbox"; + return ( @@ -100,11 +104,11 @@ export function AppSidebarPrimaryMenu({ - - Inbox + + {homeLabel} {homeBadgeCount > 0 ? ( { + await installMockBridge(page); + await page.goto("/"); + + await expectHomeLabel(page, "Inbox"); + await openHomeFilters(page); + await expect( + page.getByRole("menuitemradio", { name: "Activity", exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await openExperiments(page); + const activityToggle = page.getByTestId("feature-toggle-activity"); + await expect(activityToggle).not.toBeChecked(); + await activityToggle.click(); + await expect(activityToggle).toBeChecked(); + await page.getByTestId("settings-back-to-app").click(); + + await expectHomeLabel(page, "Activity"); + await openHomeFilters(page); + await expect( + page.getByRole("menuitemradio", { name: "Activity", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("menuitemradio", { name: "Agents", exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await openExperiments(page); + await page.getByTestId("feature-toggle-activity").click(); + await page.getByTestId("settings-back-to-app").click(); + await expectHomeLabel(page, "Inbox"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 41fdeae6fe..88216d767f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -478,10 +478,9 @@ type BridgeOptions = { skipOnboardingSeed?: boolean; skipCommunitySeed?: boolean; /** - * When true (default), seed every preview feature in preview-features.json as - * enabled in localStorage so E2E tests can interact with gated UI without - * clicking through the Experiments settings panel. Set to false in specs - * that exercise the Experiments toggle UI itself. + * When true (default), seed established preview features as enabled so E2E + * tests can interact with gated UI. Activity stays disabled as the control. + * Set to false in specs that exercise an unseeded Experiments panel. */ seedPreviewFeatures?: boolean; user?: keyof typeof TEST_IDENTITIES; @@ -693,7 +692,7 @@ async function seedPreviewFeaturesEnabled(page: Page) { await page.addInitScript( ({ key, ids }) => { const overrides: Record = {}; - for (const id of ids) overrides[id] = true; + for (const id of ids) overrides[id] = id !== "activity"; window.localStorage.setItem(key, JSON.stringify(overrides)); }, { key: FEATURE_OVERRIDES_STORAGE_KEY, ids: PREVIEW_FEATURE_IDS }, @@ -719,8 +718,9 @@ export async function installBridge(page: Page, options: BridgeOptions) { if (!options.skipOnboardingSeed) { await seedOnboardingCompletionForKnownIdentities(page, options.relayWsUrl); } - // Default to opting every preview feature in. Specs that exercise the - // Experiments toggle UI itself pass `seedPreviewFeatures: false`. + // Default to opting into established preview features. Activity remains off + // so the existing suite continues to validate Inbox as the control; its + // dedicated experiment spec exercises both states. if (options.seedPreviewFeatures !== false) { await seedPreviewFeaturesEnabled(page); } diff --git a/preview-features.json b/preview-features.json index ad8090d5ad..d0a7f2ee74 100644 --- a/preview-features.json +++ b/preview-features.json @@ -5,41 +5,37 @@ "id": "workflows", "name": "Workflows", "description": "YAML-defined automations with approval gates", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "projects", "name": "Projects", "description": "Git repository browser and collaboration", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "pulse", "name": "Pulse", "description": "Activity feed with notes, social posts, and agent activity", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "forum", "name": "Forum Channels", "description": "Forum-style threaded channels for long-form discussions", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "agentManagedProfiles", "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] + }, + { + "id": "activity", + "name": "Activity", + "description": "A redesigned inbox for threads, agent updates, reminders, and drafts", + "platforms": ["desktop"] } ] } From 42ab00db0b2775d02cc6c2560499305a8b272c12 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 23 Jul 2026 10:49:52 -0400 Subject: [PATCH 2/6] feat(desktop): refine Activity inbox behavior --- .../home/lib/activityListRows.test.mjs | 22 +- .../src/features/home/lib/activityListRows.ts | 2 +- .../home/lib/activityViewPreferences.test.mjs | 65 +++ .../home/lib/activityViewPreferences.ts | 138 ++++++ desktop/src/features/home/lib/inbox.test.mjs | 210 ++++++++++ desktop/src/features/home/lib/inbox.ts | 111 ++++- .../home/lib/inboxViewHelpers.test.mjs | 170 ++++++++ .../src/features/home/lib/inboxViewHelpers.ts | 63 ++- .../home/ui/ActivityCustomViewDialog.tsx | 101 +++++ .../features/home/ui/ActivityFilterMenu.tsx | 183 ++++++++ desktop/src/features/home/ui/HomeView.tsx | 333 +++++++-------- .../src/features/home/ui/InboxDetailPane.tsx | 155 ++++--- .../src/features/home/ui/InboxListPane.tsx | 198 ++++----- .../src/features/home/ui/InboxMessageRow.tsx | 4 + .../features/home/useActivityInboxFilter.ts | 61 ++- 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 +- .../features/home/useHomePersonalActivity.ts | 19 +- .../features/home/useInboxThreadContext.ts | 116 +++++- desktop/tests/e2e/channels.spec.ts | 392 +++++++++++++++++- 23 files changed, 2184 insertions(+), 415 deletions(-) create mode 100644 desktop/src/features/home/lib/activityViewPreferences.test.mjs create mode 100644 desktop/src/features/home/lib/activityViewPreferences.ts create mode 100644 desktop/src/features/home/ui/ActivityCustomViewDialog.tsx create mode 100644 desktop/src/features/home/ui/ActivityFilterMenu.tsx create mode 100644 desktop/src/features/home/useHomeInboxAutoSelection.ts create mode 100644 desktop/src/features/home/useHomeInboxContextMessages.ts diff --git a/desktop/src/features/home/lib/activityListRows.test.mjs b/desktop/src/features/home/lib/activityListRows.test.mjs index 3044454225..5e02a00051 100644 --- a/desktop/src/features/home/lib/activityListRows.test.mjs +++ b/desktop/src/features/home/lib/activityListRows.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import { buildActivityListRows } from "./activityListRows.ts"; function inboxItem(id, latestActivityAt) { - return { id, latestActivityAt }; + return { conversationId: `conversation:${id}`, id, latestActivityAt }; } function draftItem(key, updatedAt, rootStatus = "available") { @@ -43,3 +43,23 @@ test("Activity All excludes completed reminders and deleted-root drafts", () => assert.deepEqual(rows, []); }); + +test("Activity conversation keys stay stable when the representative changes", () => { + const first = buildActivityListRows({ + drafts: [], + items: [ + { conversationId: "thread-root", id: "reply-1", latestActivityAt: 1 }, + ], + reminders: [], + }); + const second = buildActivityListRows({ + drafts: [], + items: [ + { conversationId: "thread-root", id: "reply-2", latestActivityAt: 2 }, + ], + reminders: [], + }); + + assert.equal(first[0].key, "inbox:thread-root"); + assert.equal(second[0].key, first[0].key); +}); diff --git a/desktop/src/features/home/lib/activityListRows.ts b/desktop/src/features/home/lib/activityListRows.ts index 951d07bdd0..7d263dc1f7 100644 --- a/desktop/src/features/home/lib/activityListRows.ts +++ b/desktop/src/features/home/lib/activityListRows.ts @@ -45,7 +45,7 @@ export function buildActivityListRows({ return [ ...items.map( (item): ActivityListRow => ({ - key: `inbox:${item.id}`, + key: `inbox:${item.conversationId}`, kind: "inbox", item, sortAt: item.latestActivityAt, diff --git a/desktop/src/features/home/lib/activityViewPreferences.test.mjs b/desktop/src/features/home/lib/activityViewPreferences.test.mjs new file mode 100644 index 0000000000..5df1589365 --- /dev/null +++ b/desktop/src/features/home/lib/activityViewPreferences.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_ACTIVITY_CUSTOM_VIEW, + activityViewStorageKey, + parseActivityViewPreferences, +} from "./activityViewPreferences.ts"; + +test("activity view preferences keep a valid saved default and custom mix", () => { + assert.deepEqual( + parseActivityViewPreferences({ + version: 1, + defaultView: "custom", + custom: { + dms: true, + mentions: false, + agentReplies: false, + }, + }), + { + version: 1, + defaultView: "custom", + custom: { + ...DEFAULT_ACTIVITY_CUSTOM_VIEW, + mentions: false, + agentReplies: false, + }, + }, + ); +}); + +test("activity view preferences reject unknown versions and default invalid views to All", () => { + assert.equal( + parseActivityViewPreferences({ version: 2, defaultView: "custom" }), + null, + ); + assert.deepEqual( + parseActivityViewPreferences({ + version: 1, + defaultView: "surprise", + custom: null, + }), + { + version: 1, + defaultView: "all", + custom: { ...DEFAULT_ACTIVITY_CUSTOM_VIEW }, + }, + ); +}); + +test("activity view preferences are scoped by identity and normalized relay", () => { + assert.equal( + activityViewStorageKey("alice", "WSS://EXAMPLE.COM/"), + activityViewStorageKey("alice", "wss://example.com"), + ); + assert.notEqual( + activityViewStorageKey("alice", "wss://one.example"), + activityViewStorageKey("alice", "wss://two.example"), + ); + assert.notEqual( + activityViewStorageKey("alice", "wss://one.example"), + activityViewStorageKey("bob", "wss://one.example"), + ); +}); diff --git a/desktop/src/features/home/lib/activityViewPreferences.ts b/desktop/src/features/home/lib/activityViewPreferences.ts new file mode 100644 index 0000000000..eeae1d0397 --- /dev/null +++ b/desktop/src/features/home/lib/activityViewPreferences.ts @@ -0,0 +1,138 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; + +const STORAGE_KEY_PREFIX = "buzz-activity-view.v1"; + +export type ActivityViewId = + | "all" + | "mention" + | "thread" + | "needs_action" + | "agent_activity" + | "reminders" + | "drafts" + | "custom"; + +export type ActivityCustomView = { + dms: boolean; + mentions: boolean; + threads: boolean; + needsAction: boolean; + agentReplies: boolean; + dueReminders: boolean; + drafts: boolean; +}; + +export type ActivityViewPreferences = { + version: 1; + defaultView: ActivityViewId; + custom: ActivityCustomView; +}; + +export const DEFAULT_ACTIVITY_CUSTOM_VIEW: ActivityCustomView = Object.freeze({ + dms: true, + mentions: true, + threads: true, + needsAction: true, + agentReplies: true, + dueReminders: true, + drafts: true, +}); + +export const DEFAULT_ACTIVITY_VIEW_PREFERENCES: ActivityViewPreferences = + Object.freeze({ + version: 1, + defaultView: "all", + custom: DEFAULT_ACTIVITY_CUSTOM_VIEW, + }); + +const ACTIVITY_VIEW_IDS = new Set([ + "all", + "mention", + "thread", + "needs_action", + "agent_activity", + "reminders", + "drafts", + "custom", +]); + +function isActivityViewId(value: unknown): value is ActivityViewId { + return ( + typeof value === "string" && ACTIVITY_VIEW_IDS.has(value as ActivityViewId) + ); +} + +function parseCustomView(value: unknown): ActivityCustomView { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { ...DEFAULT_ACTIVITY_CUSTOM_VIEW }; + } + + const candidate = value as Record; + return Object.fromEntries( + Object.entries(DEFAULT_ACTIVITY_CUSTOM_VIEW).map(([key, fallback]) => [ + key, + typeof candidate[key] === "boolean" ? candidate[key] : fallback, + ]), + ) as ActivityCustomView; +} + +export function parseActivityViewPreferences( + value: unknown, +): ActivityViewPreferences | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + + const candidate = value as Record; + if (candidate.version !== 1) return null; + + return { + version: 1, + defaultView: isActivityViewId(candidate.defaultView) + ? candidate.defaultView + : "all", + custom: parseCustomView(candidate.custom), + }; +} + +export function activityViewStorageKey( + pubkey: string, + relayUrl?: string, +): string { + if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; + return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +export function readActivityViewPreferences( + pubkey: string, + relayUrl?: string, +): ActivityViewPreferences { + try { + const raw = window.localStorage.getItem( + activityViewStorageKey(pubkey, relayUrl), + ); + if (!raw) return DEFAULT_ACTIVITY_VIEW_PREFERENCES; + return ( + parseActivityViewPreferences(JSON.parse(raw)) ?? + DEFAULT_ACTIVITY_VIEW_PREFERENCES + ); + } catch { + return DEFAULT_ACTIVITY_VIEW_PREFERENCES; + } +} + +export function writeActivityViewPreferences( + pubkey: string, + preferences: ActivityViewPreferences, + relayUrl?: string, +): boolean { + try { + window.localStorage.setItem( + activityViewStorageKey(pubkey, relayUrl), + JSON.stringify(preferences), + ); + return true; + } catch { + return false; + } +} 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..9e5553b06d 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -24,7 +24,8 @@ export type InboxFilter = | "activity" | "agent_activity" | "reminders" - | "drafts"; + | "drafts" + | "custom"; export type InboxItem = { avatarUrl: string | null; @@ -50,6 +51,7 @@ export type InboxItem = { senderLabel: string; subject: string; timestampLabel: string; + unreadCount: number; }; export type InboxTypeLabel = { @@ -207,6 +209,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 +324,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 +345,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 +437,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 +489,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 +513,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 +564,6 @@ export function buildInboxItems({ item.tags, profiles, ); - const groupChannel = resolveGroupChannel(item, group.items, channelById); const channelLabel = groupChannel.name; const displayItem: FeedItem = { ...item, @@ -494,6 +590,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 31532c39ad..d68a906395 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -7,7 +7,10 @@ import { filterActivityInboxItems, getContextMessageDepth, getReactionTargetId, + hasInboxThreadContext, isInboxThreadContextEvent, + matchesActivityAllView, + matchesActivityCustomView, matchesInboxFilter, toInboxContextMessage, toTimelineMessage, @@ -22,6 +25,48 @@ test("Activity uses the dedicated reminder list instead of feed reminder rows", assert.deepEqual(filterActivityInboxItems(items, true), [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", () => { @@ -29,6 +74,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"), @@ -144,6 +258,62 @@ test("matchesInboxFilter matches thread rows by thread tags", () => { ); }); +test("matchesActivityCustomView uses union matching across selected sources", () => { + const item = { + categories: ["mention"], + item: { + id: "dm", + pubkey: "person", + channelType: "dm", + tags: [], + }, + }; + const custom = { + dms: false, + mentions: true, + threads: false, + needsAction: false, + agentReplies: false, + dueReminders: false, + drafts: false, + }; + + assert.equal(matchesActivityCustomView(item, custom, new Set()), true); + assert.equal( + matchesActivityCustomView( + { ...item, categories: [] }, + { ...custom, dms: true }, + new Set(), + ), + true, + ); +}); + +test("matchesActivityCustomView only includes replies from owned agents", () => { + const item = { + categories: ["activity"], + item: { id: "reply", pubkey: "OWNED", channelType: "channel", tags: [] }, + }; + const custom = { + dms: false, + mentions: false, + threads: false, + needsAction: false, + agentReplies: true, + dueReminders: false, + drafts: false, + }; + + assert.equal( + matchesActivityCustomView(item, custom, new Set(["owned"])), + true, + ); + assert.equal( + matchesActivityCustomView(item, custom, new Set(["other"])), + false, + ); +}); + // --- getReactionTargetId --- test("getReactionTargetId returns the last e-tag target id", () => { diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 112937385a..738d10e70c 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -4,6 +4,7 @@ import { type InboxFilter, type InboxItem, } from "@/features/home/lib/inbox"; +import type { ActivityCustomView } from "@/features/home/lib/activityViewPreferences"; import { getChannelIdFromTags, getThreadReference, @@ -33,6 +34,15 @@ export function filterActivityInboxItems( : items; } +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[]; @@ -43,7 +53,9 @@ export function matchesInboxFilter( ownedAgentPubkeys?: ReadonlySet, ) { if (filter === "all") { - return true; + return ownedAgentPubkeys + ? matchesActivityAllView(item, ownedAgentPubkeys) + : true; } if (filter === "thread") { @@ -62,6 +74,55 @@ export function matchesInboxFilter( 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 matchesActivityCustomView( + item: { + categories: readonly string[]; + groupItems?: readonly FeedItem[]; + item?: FeedItem; + }, + custom: ActivityCustomView, + ownedAgentPubkeys: ReadonlySet, +): boolean { + const representative = item.item ?? item.groupItems?.at(-1); + return ( + (custom.dms && representative?.channelType === "dm") || + (custom.mentions && item.categories.includes("mention")) || + (custom.threads && + [item.item, ...(item.groupItems ?? [])].some((groupItem) => + groupItem ? hasThreadReplyTags(groupItem.tags) : false, + )) || + (custom.needsAction && item.categories.includes("needs_action")) || + (custom.agentReplies && + Boolean( + representative && + ownedAgentPubkeys.has(normalizePubkey(representative.pubkey)), + )) + ); +} + export function getContextMessageDepth( event: RelayEvent, eventById: ReadonlyMap, diff --git a/desktop/src/features/home/ui/ActivityCustomViewDialog.tsx b/desktop/src/features/home/ui/ActivityCustomViewDialog.tsx new file mode 100644 index 0000000000..df18adac4c --- /dev/null +++ b/desktop/src/features/home/ui/ActivityCustomViewDialog.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; + +import type { ActivityCustomView } from "@/features/home/lib/activityViewPreferences"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +const SOURCE_OPTIONS: Array<{ + key: keyof ActivityCustomView; + label: string; +}> = [ + { key: "dms", label: "Direct messages" }, + { key: "mentions", label: "Mentions" }, + { key: "threads", label: "Threads" }, + { key: "agentReplies", label: "Agent replies" }, + { key: "needsAction", label: "Needs action" }, + { key: "dueReminders", label: "Due reminders" }, + { key: "drafts", label: "Drafts" }, +]; + +type ActivityCustomViewDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (value: ActivityCustomView) => void; + value: ActivityCustomView; +}; + +export function ActivityCustomViewDialog({ + open, + onOpenChange, + onSave, + value, +}: ActivityCustomViewDialogProps) { + const [draft, setDraft] = React.useState(value); + + React.useEffect(() => { + if (open) setDraft(value); + }, [open, value]); + + const hasSelectedSource = SOURCE_OPTIONS.some(({ key }) => draft[key]); + const update = (key: keyof ActivityCustomView, checked: boolean) => { + setDraft((current) => ({ ...current, [key]: checked })); + }; + + return ( + + + + Custom view + + Choose what appears in your custom Activity view for this community. + + + +
+ {SOURCE_OPTIONS.map((option) => { + const id = `activity-custom-${option.key}`; + return ( + + ); + })} +
+ + + + + +
+
+ ); +} diff --git a/desktop/src/features/home/ui/ActivityFilterMenu.tsx b/desktop/src/features/home/ui/ActivityFilterMenu.tsx new file mode 100644 index 0000000000..035ceefa43 --- /dev/null +++ b/desktop/src/features/home/ui/ActivityFilterMenu.tsx @@ -0,0 +1,183 @@ +import { ChevronDown, Settings, Star } from "lucide-react"; +import * as React from "react"; + +import type { + ActivityCustomView, + ActivityViewId, +} from "@/features/home/lib/activityViewPreferences"; +import type { InboxFilter } from "@/features/home/lib/inbox"; +import { ActivityCustomViewDialog } from "@/features/home/ui/ActivityCustomViewDialog"; +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +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_FILTER_OPTIONS: Array<{ + label: string; + value: ActivityViewId; +}> = [ + { 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" }, + { value: "custom", label: "Custom" }, +]; + +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 = { + activityEnabled: boolean; + activeDraftCount: number; + customView: ActivityCustomView; + defaultView: ActivityViewId; + dueReminderCount: number; + filter: InboxFilter; + onCustomViewChange: (value: ActivityCustomView) => void; + onFilterChange: (value: InboxFilter) => void; + reminderCount: number; +}; + +export function ActivityFilterMenu({ + activityEnabled, + activeDraftCount, + customView, + defaultView, + dueReminderCount, + filter, + onCustomViewChange, + onFilterChange, + reminderCount, +}: ActivityFilterMenuProps) { + const [editorOpen, setEditorOpen] = React.useState(false); + const options = activityEnabled ? ACTIVITY_FILTER_OPTIONS : FILTER_OPTIONS; + const activeFilter = 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} + > + {options.map((option) => { + const radioItem = ( + + + {option.label} + + {activityEnabled && option.value === defaultView ? ( + + ) : null} + {option.value === "reminders" && + (activityEnabled ? reminderCount : dueReminderCount) > + 0 ? ( + + {activityEnabled ? reminderCount : dueReminderCount} + + ) : option.value === "drafts" && activeDraftCount > 0 ? ( + + {activeDraftCount} + + ) : null} + + + + ); + + if (!activityEnabled || option.value !== "custom") { + return radioItem; + } + + return ( +
+ {radioItem} + setEditorOpen(true)} + title="Edit Custom view" + > + + +
+ ); + })} +
+
+
+ + + ); +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 8e6c462eb4..81b25594dd 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -8,10 +8,10 @@ import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { - type InboxContextMessage, - type InboxItem, + type InboxFilter, type InboxReply, buildInboxItems, + findInboxItemByEventId, formatInboxFullTimestamp, getInboxConversationId, } from "@/features/home/lib/inbox"; @@ -20,11 +20,12 @@ import { useActivityInboxFilter } from "@/features/home/useActivityInboxFilter"; import { useOwnedAgentPubkeys } from "@/features/home/useOwnedAgentPubkeys"; import { filterActivityInboxItems, - getReactionTargetId, + matchesActivityCustomView, matchesInboxFilter, - toInboxContextMessage, } from "@/features/home/lib/inboxViewHelpers"; import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; +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 { @@ -50,10 +51,7 @@ 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"; @@ -81,27 +79,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 = { activityEnabled: boolean; feed?: HomeFeedResponse; @@ -132,8 +109,15 @@ export function HomeView({ const isNarrowHomeViewport = homeInboxWidthPx > 0 && homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; - const [filter, setFilter] = useActivityInboxFilter(activityEnabled); - const [unreadOnly, setUnreadOnly] = React.useState(false); + const { + filter, + preferences: activityViewPreferences, + setCustomView, + setDefaultView, + setFilter, + setUnreadOnly, + unreadOnly, + } = useActivityInboxFilter(activityEnabled, currentPubkey); // Explicit selections are mirrored to the URL (`?item=`), so back/forward // restores the detail pane each history entry was showing and reloads // restore it from the URL. Default/automatic selection stays local-only — @@ -143,6 +127,8 @@ export function HomeView({ const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; const isMessagesMode = !isReminders && !isDrafts; + const allowMixedPersonalSelection = + activityEnabled && (filter === "all" || filter === "custom"); const { drafts: { activeCount: activeDraftCount, @@ -161,6 +147,7 @@ export function HomeView({ }, } = useHomePersonalActivity({ activityEnabled, + allowMixedSelection: allowMixedPersonalSelection, currentPubkey, isDrafts, isNarrowHomeViewport, @@ -178,37 +165,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], @@ -278,6 +251,7 @@ export function HomeView({ getMessageReadAt, feedItemState, markChannelRead, + markMessageRead, markThreadRead, readStateVersion, } = useAppShell(); @@ -301,7 +275,6 @@ export function HomeView({ ? (getThreadReference(activeLatchedItem.tags).parentId ?? activeLatchedItem.id) : null; - const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data; const selectedChannelIdCandidate = React.useMemo(() => { @@ -332,6 +305,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( @@ -372,8 +352,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); @@ -386,15 +364,29 @@ export function HomeView({ return pubkeys; }, [feedProfiles, communityAgentPubkeys]); + // 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, activityEnabled); - }, [activityEnabled, channels, currentPubkey, feed, feedProfiles]); + }, [ + activityEnabled, + channels, + currentPubkey, + feed, + feedProfiles, + getChannelReadAt, + getMessageReadAt, + getThreadReadAt, + readStateVersion, + ]); const { effectiveDoneSet, markItemRead, markItemUnread } = useHomeInboxReadState({ items: inboxItems, @@ -405,20 +397,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], ); @@ -428,7 +418,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; @@ -436,16 +431,23 @@ export function HomeView({ const filteredItems = React.useMemo(() => { return inboxItems.filter( (item) => - matchesInboxFilter( - item, - filter, - activityEnabled ? ownedAgentPubkeys : undefined, - ) && + (activityEnabled && filter === "custom" + ? matchesActivityCustomView( + item, + activityViewPreferences.custom, + ownedAgentPubkeys, + ) + : matchesInboxFilter( + item, + filter, + activityEnabled ? ownedAgentPubkeys : undefined, + )) && (!unreadOnly || !effectiveDoneSet.has(item.id) || item.conversationId === selectedConversationId), ); }, [ + activityViewPreferences.custom, effectiveDoneSet, activityEnabled, filter, @@ -460,8 +462,7 @@ export function HomeView({ // filter) does not make selectedItem go null mid-session. 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; @@ -488,62 +489,25 @@ export function HomeView({ selectedEventId, selectedItemFromAll, ]); - const contextMessages = React.useMemo(() => { - if (!selectedItem) { - return []; + 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 = @@ -551,73 +515,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; @@ -625,6 +536,22 @@ export function HomeView({ setIsSendingReply(false); }, [selectedConversationId]); + const handleFilterChange = React.useCallback( + (nextFilter: InboxFilter) => { + setUnreadBoundary(null); + setSelectedDraftKey(null); + setSelectedReminderId(null); + handleUserSelectItem(null); + setFilter(nextFilter); + }, + [ + handleUserSelectItem, + setFilter, + setSelectedDraftKey, + setSelectedReminderId, + ], + ); + if (isLoading && !feed) { return ; } @@ -656,6 +583,15 @@ export function HomeView({ currentPubkey, availableChannelIds, ); + const detailMode = isDrafts + ? "drafts" + : isReminders + ? "reminders" + : selectedDraftItem + ? "drafts" + : selectedReminder + ? "reminders" + : "messages"; const { auxiliaryPaneWidthPx, effectiveInboxListWidthPx, @@ -669,10 +605,10 @@ export function HomeView({ hasAuxiliaryPane, homeWidthPx: homeInboxWidthPx, inboxListWidthPx, - isDrafts, - isMessagesMode, + isDrafts: detailMode === "drafts", + isMessagesMode: detailMode === "messages", isNarrow: isNarrowHomeViewport, - isReminders, + isReminders: detailMode === "reminders", isSinglePanelAuxiliaryView, selectedDraft: selectedDraftItem !== null, selectedEvent: selectedEventId !== null, @@ -719,13 +655,17 @@ export function HomeView({ activeReminderEventIds={activeReminderEventIds} agentPubkeys={inboxAgentPubkeys} activeDraftCount={activeDraftCount} + customView={activityViewPreferences.custom} + defaultView={activityViewPreferences.defaultView} draftItems={draftItems} doneSet={effectiveDoneSet} dueReminderCount={dueReminderCount} filter={filter} items={filteredItems} onDeleteDraft={handleDeleteDraft} - onFilterChange={setFilter} + onCustomViewChange={setCustomView} + onDefaultViewChange={setDefaultView} + onFilterChange={handleFilterChange} onMarkRead={markItemRead} onMarkUnread={markItemUnread} onOpenDirect={(item) => { @@ -752,11 +692,32 @@ 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} - onSelectReminder={setSelectedReminderId} + 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} @@ -791,7 +752,7 @@ export function HomeView({ - {showDetailPane && isMessagesMode ? ( + {showDetailPane && detailMode === "messages" ? ( { @@ -928,11 +891,11 @@ export function HomeView({ replies={selectedItemReplies} /> ) : null} - {showDetailPane && (isDrafts || isReminders) ? ( + {showDetailPane && detailMode !== "messages" ? ( setSelectedDraftKey(null) diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 23a1748225..d472431abd 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) => @@ -411,45 +443,44 @@ export function InboxDetailPane({ ) : null}
- {canOpenChannel && contextChannelId ? ( - - ) : ( -

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

- )} +

+ + {contextLabel} + +

+ {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 +560,7 @@ export function InboxDetailPane({ message={message} onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} + showUnreadBoundary={hasUnreadBoundary} /> ); })} @@ -543,17 +594,25 @@ export function InboxDetailPane({ />
setReplyTargetId(null) : undefined @@ -568,7 +627,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 389a47d861..8828c51080 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,11 +1,11 @@ import { Bell, - ChevronDown, Clock, Ellipsis, ExternalLink, FileText, MailOpen, + Star, } from "lucide-react"; import * as React from "react"; @@ -16,6 +16,11 @@ import { type InboxTypeLabel, } from "@/features/home/lib/inbox"; import { buildActivityListRows } from "@/features/home/lib/activityListRows"; +import type { + ActivityCustomView, + ActivityViewId, +} from "@/features/home/lib/activityViewPreferences"; +import { ActivityFilterMenu } from "@/features/home/ui/ActivityFilterMenu"; import { DraftsPanel, getDraftPreview, @@ -23,6 +28,7 @@ import { } from "@/features/messages/ui/DraftsPanel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { isDue } from "@/features/reminders/lib/reminderFilters"; import { RemindersPanel, useReminderSources, @@ -42,13 +48,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"; @@ -56,30 +55,6 @@ 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_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 ACTIVITY_EMPTY_STATE_TITLES: Record = { all: "No activity yet", mention: "No mentions found", @@ -89,6 +64,7 @@ const ACTIVITY_EMPTY_STATE_TITLES: Record = { agent_activity: "No agent updates found", reminders: "No reminders", drafts: "No drafts", + custom: "No custom activity found", }; const ACTIVITY_UNREAD_EMPTY_STATE_TITLES: Record = { @@ -100,6 +76,7 @@ const ACTIVITY_UNREAD_EMPTY_STATE_TITLES: Record = { agent_activity: "No unread agent updates", reminders: "No unread reminders", drafts: "No unread drafts", + custom: "No unread custom activity", }; const INBOX_HEADER_ICON_BUTTON_CLASS = @@ -164,6 +141,7 @@ function PersonalItemRow({ location, onClick, preview, + selected, status, }: { id: string; @@ -171,6 +149,7 @@ function PersonalItemRow({ location: InboxTypeLabel | null; onClick: () => void; preview: string; + selected: boolean; status: string; }) { const isDraft = kind === "drafts"; @@ -178,7 +157,11 @@ function PersonalItemRow({ return (
@@ -546,6 +550,20 @@ export function InboxListPane({ + {currentActivityView ? ( + <> + + + + ) : null}
- - - - - - - onFilterChange(value as InboxFilter) - } - value={filter} - > - {filterOptions.map((option) => ( - - - {option.label} - {option.value === "reminders" && - (activityEnabled - ? reminders.length - : dueReminderCount) > 0 ? ( - - {activityEnabled - ? reminders.length - : dueReminderCount} - - ) : option.value === "drafts" && - activeDraftCount > 0 ? ( - - {activeDraftCount} - - ) : null} - - - ))} - - - +
@@ -692,7 +652,7 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > - {activityEnabled && filter === "all" && activityRows.length > 0 ? ( + {isMixedActivityView && activityRows.length > 0 ? ( row.key} @@ -718,13 +678,13 @@ export function InboxListPane({ } onClick={() => { onSelectReminder(row.reminder.id); - onFilterChange("reminders"); }} preview={ row.reminder.content.target?.preview || row.reminder.content.note || "Reminder" } + selected={selectedReminderId === row.reminder.id} status={formatReminderStatus(row.reminder.notBefore)} /> ); @@ -747,9 +707,9 @@ export function InboxListPane({ } onClick={() => { onSelectDraft(entry.key); - onFilterChange("drafts"); }} preview={getDraftPreview(entry.draft)} + selected={selectedDraftKey === entry.key} status="Draft saved" /> ); 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 ? ( ) : null}

@@ -437,10 +446,7 @@ export function ReminderDetailPane({ {source.authorLabel} in - - {source.channel?.channelType === "dm" ? "" : "#"} - {source.channelLabel} - + {formatReminderSourceLocation(source)}

) : null} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index e41adaabab..20b21baacf 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2867,6 +2867,80 @@ test("Activity All keeps its filter when opening a due reminder", async ({ await expect(page.getByTestId("home-inbox-list")).toBeVisible(); }); +test("Activity reminder rows and detail identify DM context", async ({ + page, +}) => { + await page.addInitScript((key) => { + const overrides = JSON.parse( + window.localStorage.getItem(key) ?? "{}", + ) as Record; + window.localStorage.setItem( + key, + JSON.stringify({ ...overrides, activity: true }), + ); + }, FEATURE_OVERRIDES_STORAGE_KEY); + await page.goto("/"); + + const reminderId = "activity-dm-reminder"; + const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; + await page.evaluate( + async ({ authorPubkey, channelId, currentPubkey, reminderId }) => { + const now = Math.floor(Date.now() / 1_000); + window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([ + { + id: reminderId, + pubkey: currentPubkey, + created_at: now - 300, + kind: 30300, + tags: [ + ["d", reminderId], + ["not_before", String(now - 60)], + ], + content: JSON.stringify({ + target: { + eventId: "mock-dm-alice", + channelId, + preview: "Follow up with Alice", + authorPubkey, + }, + status: "pending", + }), + sig: "mocksig".repeat(20).slice(0, 128), + }, + ]); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["reminders"], + }); + }, + { + authorPubkey: TEST_IDENTITIES.alice.pubkey, + channelId: dmChannelId, + currentPubkey: MOCK_IDENTITY_PUBKEY, + reminderId, + }, + ); + + await expect( + page.getByTestId(`home-all-reminders-${reminderId}`), + ).toContainText("In DM with alice-tyler"); + + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Reminders" }).click(); + const reminderRow = page.getByTestId(`home-reminder-item-${reminderId}`); + await expect( + reminderRow.getByText("DM with alice-tyler", { exact: true }), + ).toBeVisible(); + await reminderRow.getByRole("button").click(); + + const detail = page.getByTestId("home-reminder-detail"); + await expect( + detail.getByText("DM with alice-tyler", { exact: true }), + ).toBeVisible(); + await expect(detail.getByText("#alice-tyler", { exact: true })).toHaveCount( + 0, + ); +}); + test("home inbox source action navigates to the channel message", async ({ page, }) => { From 389028152fa88dbafa74223b220cb4a6ac67f015 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 23 Jul 2026 13:00:46 -0400 Subject: [PATCH 5/6] fix(desktop): preserve inbox draft selection --- desktop/src/features/home/useHomePersonalActivity.ts | 2 +- desktop/tests/e2e/reminders.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/home/useHomePersonalActivity.ts b/desktop/src/features/home/useHomePersonalActivity.ts index 0deeee60c6..6f3d263902 100644 --- a/desktop/src/features/home/useHomePersonalActivity.ts +++ b/desktop/src/features/home/useHomePersonalActivity.ts @@ -75,7 +75,7 @@ export function useHomePersonalActivity({ const drafts = useHomeDrafts({ autoSelect: isDrafts, isNarrowHomeViewport, - selectionEnabled: activityEnabled && (isDrafts || allowMixedSelection), + selectionEnabled: isDrafts || (activityEnabled && allowMixedSelection), viewportWidthPx, }); diff --git a/desktop/tests/e2e/reminders.spec.ts b/desktop/tests/e2e/reminders.spec.ts index e6a5a55744..fbdeebe43f 100644 --- a/desktop/tests/e2e/reminders.spec.ts +++ b/desktop/tests/e2e/reminders.spec.ts @@ -306,7 +306,7 @@ test.describe("reminders phase 2 — author, source, navigation", () => { remindersPanel.getByText("alice", { exact: true }), ).toBeVisible(); await expect( - remindersPanel.getByText("general", { exact: true }), + remindersPanel.getByText("#general", { exact: true }), ).toBeVisible(); await expect(remindersPanel.getByText("Reply to Alice")).toBeVisible(); await waitForAnimations(page); From 28f552a875baacd4bbc577212d8c706aa0acf049 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 24 Jul 2026 15:00:52 -0400 Subject: [PATCH 6/6] refactor(desktop): ship focused Activity feed --- desktop/playwright.config.ts | 1 - desktop/src/app/AppShell.tsx | 5 - .../app/useThreadActivityFeedItems.test.mjs | 8 +- desktop/src/app/useThreadActivityFeedItems.ts | 18 +- .../channels/useLiveChannelUpdates.ts | 12 +- .../home/lib/activityListRows.test.mjs | 77 +++++- .../src/features/home/lib/activityListRows.ts | 57 ++++- .../home/lib/activitySelection.test.mjs | 53 +++++ .../features/home/lib/activitySelection.ts | 21 ++ .../home/lib/activityViewPreferences.test.mjs | 65 ----- .../home/lib/activityViewPreferences.ts | 138 ----------- .../src/features/home/lib/homePaneLayout.ts | 6 +- desktop/src/features/home/lib/inbox.ts | 4 +- .../home/lib/inboxViewHelpers.test.mjs | 60 +---- .../src/features/home/lib/inboxViewHelpers.ts | 36 +-- .../home/ui/ActivityCustomViewDialog.tsx | 101 -------- .../features/home/ui/ActivityFilterMenu.tsx | 185 +++++---------- desktop/src/features/home/ui/HomeScreen.tsx | 3 - desktop/src/features/home/ui/HomeView.tsx | 110 ++++----- .../src/features/home/ui/InboxDetailPane.tsx | 38 ++- .../src/features/home/ui/InboxListPane.tsx | 142 ++++------- .../features/home/useActivityInboxFilter.ts | 68 ------ .../features/home/useHomePersonalActivity.ts | 8 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 14 +- desktop/tests/e2e/activity-experiment.spec.ts | 65 ----- desktop/tests/e2e/channels.spec.ts | 224 ++++++++++-------- desktop/tests/e2e/drafts-screenshots.spec.ts | 17 +- desktop/tests/e2e/reminders.spec.ts | 32 ++- desktop/tests/e2e/smoke.spec.ts | 71 +----- desktop/tests/helpers/bridge.ts | 2 +- preview-features.json | 6 - 31 files changed, 547 insertions(+), 1100 deletions(-) create mode 100644 desktop/src/features/home/lib/activitySelection.test.mjs create mode 100644 desktop/src/features/home/lib/activitySelection.ts delete mode 100644 desktop/src/features/home/lib/activityViewPreferences.test.mjs delete mode 100644 desktop/src/features/home/lib/activityViewPreferences.ts delete mode 100644 desktop/src/features/home/ui/ActivityCustomViewDialog.tsx delete mode 100644 desktop/src/features/home/useActivityInboxFilter.ts delete mode 100644 desktop/tests/e2e/activity-experiment.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index eb5c845606..5046b29688 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,7 +20,6 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", - "**/activity-experiment.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e2af5c0c33..877cd948ad 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,7 +96,6 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; -import { useFeatureEnabled } from "@/shared/features"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); @@ -107,8 +106,6 @@ export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); - const activityEnabled = useFeatureEnabled("activity"); - const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); @@ -342,7 +339,6 @@ export function AppShell() { muteThread, unmuteThread, } = useUnreadChannels(sidebarChannels, activeChannel, { - includeDmHomeActivity: activityEnabled, pubkey: identityQuery.data?.pubkey, relayClient, relayUrl: communitiesHook.activeCommunity?.relayUrl, @@ -403,7 +399,6 @@ export function AppShell() { threadActivityItems, mutedRootIds, channels, - activityEnabled, ); // Badge count consumes the shared NIP-RS read-state from useUnreadChannels. diff --git a/desktop/src/app/useThreadActivityFeedItems.test.mjs b/desktop/src/app/useThreadActivityFeedItems.test.mjs index 12e0d888be..54d36dc1e8 100644 --- a/desktop/src/app/useThreadActivityFeedItems.test.mjs +++ b/desktop/src/app/useThreadActivityFeedItems.test.mjs @@ -114,7 +114,7 @@ test("channel-presence fence applies before mute filter — unknown channel + mu assert.deepEqual(items, []); }); -test("top-level DM activity is gated by the Activity experiment", () => { +test("top-level DM activity is included in Activity", () => { const dmItem = threadActivityItem({ id: "agent-dm-reply", tags: [["h", CHANNEL_ID]], @@ -125,12 +125,6 @@ test("top-level DM activity is gated by the Activity experiment", () => { buildThreadActivityFeedItems([dmItem], new Set(), channels).map( (item) => item.id, ), - [], - ); - assert.deepEqual( - buildThreadActivityFeedItems([dmItem], new Set(), channels, true).map( - (item) => item.id, - ), ["agent-dm-reply"], ); }); diff --git a/desktop/src/app/useThreadActivityFeedItems.ts b/desktop/src/app/useThreadActivityFeedItems.ts index 29e7196702..b14eb51327 100644 --- a/desktop/src/app/useThreadActivityFeedItems.ts +++ b/desktop/src/app/useThreadActivityFeedItems.ts @@ -8,7 +8,6 @@ export function buildThreadActivityFeedItems( threadActivityItems: ThreadActivityItem[], mutedRootIds: ReadonlySet, channels: Channel[], - includeDmHomeActivity = false, ): FeedItem[] { const channelById = new Map(channels.map((channel) => [channel.id, channel])); @@ -20,13 +19,6 @@ export function buildThreadActivityFeedItems( // this filter is the last line of defense against cross-community leaks. const channel = channelById.get(item.channelId); if (channel === undefined) return false; - if ( - channel.channelType === "dm" && - getThreadReference(item.tags).parentId === null && - !includeDmHomeActivity - ) { - return false; - } const rootId = getThreadReference(item.tags).rootId; return !rootId || !mutedRootIds.has(rootId); }) @@ -51,7 +43,6 @@ export function useThreadActivityFeedItems( threadActivityItems: ThreadActivityItem[], mutedRootIds: ReadonlySet, channels: Channel[], - includeDmHomeActivity = false, ): FeedItem[] { const mutedRootIdsKey = [...mutedRootIds].sort().join("\0"); @@ -62,13 +53,6 @@ export function useThreadActivityFeedItems( threadActivityItems, mutedRootIds, channels, - includeDmHomeActivity, ); - }, [ - channels, - includeDmHomeActivity, - mutedRootIds, - mutedRootIdsKey, - threadActivityItems, - ]); + }, [channels, mutedRootIds, mutedRootIdsKey, threadActivityItems]); } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 5247784732..aeb3abb905 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -25,7 +25,6 @@ import { refreshChannelsWhenIdle } from "./refreshChannelsWhenIdle"; export type UseLiveChannelUpdatesOptions = { currentPubkey?: string; - includeDmHomeActivity?: boolean; /** * When true, DM notifications also fire for the channel the user is * currently viewing (normally suppressed). @@ -92,9 +91,8 @@ export function isChannelUnreadTriggerKind(kind: number, isDmChannel: boolean) { export function isHomeActivityEvent( isDmChannel: boolean, isThreadedReply: boolean, - includeDmHomeActivity = false, ) { - return isThreadedReply || (isDmChannel && includeDmHomeActivity); + return isThreadedReply || isDmChannel; } export function withChannelTagFallback( @@ -287,13 +285,7 @@ export function useLiveChannelUpdates( } } else { options.onChannelMessage?.(channelId, event); - if ( - isHomeActivityEvent( - isDmChannel, - isThreadedReply, - options.includeDmHomeActivity, - ) - ) { + 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 index 5e02a00051..4836e47a21 100644 --- a/desktop/src/features/home/lib/activityListRows.test.mjs +++ b/desktop/src/features/home/lib/activityListRows.test.mjs @@ -3,8 +3,18 @@ import test from "node:test"; import { buildActivityListRows } from "./activityListRows.ts"; -function inboxItem(id, latestActivityAt) { - return { conversationId: `conversation:${id}`, id, latestActivityAt }; +function inboxItem( + id, + latestActivityAt, + conversationId = `conversation:${id}`, +) { + return { + conversationId, + groupItems: [], + id, + item: { id }, + latestActivityAt, + }; } function draftItem(key, updatedAt, rootStatus = "available") { @@ -17,8 +27,21 @@ function draftItem(key, updatedAt, rootStatus = "available") { }; } -function reminder(id, createdAt, status = "pending") { - return { id, createdAt, content: { status } }; +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", () => { @@ -47,19 +70,53 @@ test("Activity All excludes completed reminders and deleted-root drafts", () => test("Activity conversation keys stay stable when the representative changes", () => { const first = buildActivityListRows({ drafts: [], - items: [ - { conversationId: "thread-root", id: "reply-1", latestActivityAt: 1 }, - ], + items: [inboxItem("reply-1", 1, "thread-root")], reminders: [], }); const second = buildActivityListRows({ drafts: [], - items: [ - { conversationId: "thread-root", id: "reply-2", latestActivityAt: 2 }, - ], + 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 index 7d263dc1f7..9ea538e38c 100644 --- a/desktop/src/features/home/lib/activityListRows.ts +++ b/desktop/src/features/home/lib/activityListRows.ts @@ -7,6 +7,7 @@ export type ActivityListRow = key: string; kind: "inbox"; item: InboxItem; + dueReminder?: Reminder; sortAt: number; } | { @@ -42,23 +43,59 @@ export function buildActivityListRows({ 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 [ - ...items.map( - (item): ActivityListRow => ({ - key: `inbox:${item.conversationId}`, - kind: "inbox", - item, - sortAt: item.latestActivityAt, - }), - ), + ...inboxRows, ...reminders - .filter((reminder) => reminder.content.status === "pending") + .filter( + (reminder) => + reminder.content.status === "pending" && + !consumedReminderIds.has(reminder.id), + ) .map( (reminder): ActivityListRow => ({ key: `reminder:${reminder.id}`, kind: "reminder", reminder, - sortAt: reminder.createdAt, + sortAt: reminder.notBefore ?? reminder.createdAt, }), ), ...drafts 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/activityViewPreferences.test.mjs b/desktop/src/features/home/lib/activityViewPreferences.test.mjs deleted file mode 100644 index 5df1589365..0000000000 --- a/desktop/src/features/home/lib/activityViewPreferences.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEFAULT_ACTIVITY_CUSTOM_VIEW, - activityViewStorageKey, - parseActivityViewPreferences, -} from "./activityViewPreferences.ts"; - -test("activity view preferences keep a valid saved default and custom mix", () => { - assert.deepEqual( - parseActivityViewPreferences({ - version: 1, - defaultView: "custom", - custom: { - dms: true, - mentions: false, - agentReplies: false, - }, - }), - { - version: 1, - defaultView: "custom", - custom: { - ...DEFAULT_ACTIVITY_CUSTOM_VIEW, - mentions: false, - agentReplies: false, - }, - }, - ); -}); - -test("activity view preferences reject unknown versions and default invalid views to All", () => { - assert.equal( - parseActivityViewPreferences({ version: 2, defaultView: "custom" }), - null, - ); - assert.deepEqual( - parseActivityViewPreferences({ - version: 1, - defaultView: "surprise", - custom: null, - }), - { - version: 1, - defaultView: "all", - custom: { ...DEFAULT_ACTIVITY_CUSTOM_VIEW }, - }, - ); -}); - -test("activity view preferences are scoped by identity and normalized relay", () => { - assert.equal( - activityViewStorageKey("alice", "WSS://EXAMPLE.COM/"), - activityViewStorageKey("alice", "wss://example.com"), - ); - assert.notEqual( - activityViewStorageKey("alice", "wss://one.example"), - activityViewStorageKey("alice", "wss://two.example"), - ); - assert.notEqual( - activityViewStorageKey("alice", "wss://one.example"), - activityViewStorageKey("bob", "wss://one.example"), - ); -}); diff --git a/desktop/src/features/home/lib/activityViewPreferences.ts b/desktop/src/features/home/lib/activityViewPreferences.ts deleted file mode 100644 index eeae1d0397..0000000000 --- a/desktop/src/features/home/lib/activityViewPreferences.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; - -const STORAGE_KEY_PREFIX = "buzz-activity-view.v1"; - -export type ActivityViewId = - | "all" - | "mention" - | "thread" - | "needs_action" - | "agent_activity" - | "reminders" - | "drafts" - | "custom"; - -export type ActivityCustomView = { - dms: boolean; - mentions: boolean; - threads: boolean; - needsAction: boolean; - agentReplies: boolean; - dueReminders: boolean; - drafts: boolean; -}; - -export type ActivityViewPreferences = { - version: 1; - defaultView: ActivityViewId; - custom: ActivityCustomView; -}; - -export const DEFAULT_ACTIVITY_CUSTOM_VIEW: ActivityCustomView = Object.freeze({ - dms: true, - mentions: true, - threads: true, - needsAction: true, - agentReplies: true, - dueReminders: true, - drafts: true, -}); - -export const DEFAULT_ACTIVITY_VIEW_PREFERENCES: ActivityViewPreferences = - Object.freeze({ - version: 1, - defaultView: "all", - custom: DEFAULT_ACTIVITY_CUSTOM_VIEW, - }); - -const ACTIVITY_VIEW_IDS = new Set([ - "all", - "mention", - "thread", - "needs_action", - "agent_activity", - "reminders", - "drafts", - "custom", -]); - -function isActivityViewId(value: unknown): value is ActivityViewId { - return ( - typeof value === "string" && ACTIVITY_VIEW_IDS.has(value as ActivityViewId) - ); -} - -function parseCustomView(value: unknown): ActivityCustomView { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return { ...DEFAULT_ACTIVITY_CUSTOM_VIEW }; - } - - const candidate = value as Record; - return Object.fromEntries( - Object.entries(DEFAULT_ACTIVITY_CUSTOM_VIEW).map(([key, fallback]) => [ - key, - typeof candidate[key] === "boolean" ? candidate[key] : fallback, - ]), - ) as ActivityCustomView; -} - -export function parseActivityViewPreferences( - value: unknown, -): ActivityViewPreferences | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return null; - } - - const candidate = value as Record; - if (candidate.version !== 1) return null; - - return { - version: 1, - defaultView: isActivityViewId(candidate.defaultView) - ? candidate.defaultView - : "all", - custom: parseCustomView(candidate.custom), - }; -} - -export function activityViewStorageKey( - pubkey: string, - relayUrl?: string, -): string { - if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; - return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; -} - -export function readActivityViewPreferences( - pubkey: string, - relayUrl?: string, -): ActivityViewPreferences { - try { - const raw = window.localStorage.getItem( - activityViewStorageKey(pubkey, relayUrl), - ); - if (!raw) return DEFAULT_ACTIVITY_VIEW_PREFERENCES; - return ( - parseActivityViewPreferences(JSON.parse(raw)) ?? - DEFAULT_ACTIVITY_VIEW_PREFERENCES - ); - } catch { - return DEFAULT_ACTIVITY_VIEW_PREFERENCES; - } -} - -export function writeActivityViewPreferences( - pubkey: string, - preferences: ActivityViewPreferences, - relayUrl?: string, -): boolean { - try { - window.localStorage.setItem( - activityViewStorageKey(pubkey, relayUrl), - JSON.stringify(preferences), - ); - return true; - } catch { - return false; - } -} diff --git a/desktop/src/features/home/lib/homePaneLayout.ts b/desktop/src/features/home/lib/homePaneLayout.ts index ac409ea103..dd4871c888 100644 --- a/desktop/src/features/home/lib/homePaneLayout.ts +++ b/desktop/src/features/home/lib/homePaneLayout.ts @@ -1,7 +1,6 @@ import { INBOX_COLUMN_MIN_WIDTH_PX } from "@/features/home/useResizableInboxListWidth"; type HomePaneLayoutOptions = { - activityEnabled: boolean; hasAuxiliaryPane: boolean; homeWidthPx: number; inboxListWidthPx: number; @@ -28,7 +27,6 @@ export function getHomePaneLayout(options: HomePaneLayoutOptions) { options.selectedDraft && !options.isSinglePanelAuxiliaryView; const singleReminder = - options.activityEnabled && options.isReminders && options.isNarrow && options.selectedReminder && @@ -42,9 +40,7 @@ export function getHomePaneLayout(options: HomePaneLayoutOptions) { !options.isSinglePanelAuxiliaryView && ((options.isMessagesMode && (!options.isNarrow || singleMessage)) || (options.isDrafts && (!options.isNarrow || singleDraft)) || - (options.activityEnabled && - options.isReminders && - (!options.isNarrow || singleReminder))); + (options.isReminders && (!options.isNarrow || singleReminder))); const auxiliaryWidth = options.isSinglePanelAuxiliaryView ? options.homeWidthPx : options.threadPanelWidthPx; diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 9e5553b06d..97cfc782ea 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -21,11 +21,9 @@ export type InboxFilter = | "mention" | "thread" | "needs_action" - | "activity" | "agent_activity" | "reminders" - | "drafts" - | "custom"; + | "drafts"; export type InboxItem = { avatarUrl: string | null; diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index d68a906395..eb0e7f70b1 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -10,7 +10,6 @@ import { hasInboxThreadContext, isInboxThreadContextEvent, matchesActivityAllView, - matchesActivityCustomView, matchesInboxFilter, toInboxContextMessage, toTimelineMessage, @@ -21,8 +20,7 @@ test("Activity uses the dedicated reminder list instead of feed reminder rows", const reminder = { item: { kind: 40007 } }; const items = [message, reminder]; - assert.equal(filterActivityInboxItems(items, false), items); - assert.deepEqual(filterActivityInboxItems(items, true), [message]); + assert.deepEqual(filterActivityInboxItems(items), [message]); }); test("hasInboxThreadContext finds replies in the grouped row or loaded context", () => { @@ -258,62 +256,6 @@ test("matchesInboxFilter matches thread rows by thread tags", () => { ); }); -test("matchesActivityCustomView uses union matching across selected sources", () => { - const item = { - categories: ["mention"], - item: { - id: "dm", - pubkey: "person", - channelType: "dm", - tags: [], - }, - }; - const custom = { - dms: false, - mentions: true, - threads: false, - needsAction: false, - agentReplies: false, - dueReminders: false, - drafts: false, - }; - - assert.equal(matchesActivityCustomView(item, custom, new Set()), true); - assert.equal( - matchesActivityCustomView( - { ...item, categories: [] }, - { ...custom, dms: true }, - new Set(), - ), - true, - ); -}); - -test("matchesActivityCustomView only includes replies from owned agents", () => { - const item = { - categories: ["activity"], - item: { id: "reply", pubkey: "OWNED", channelType: "channel", tags: [] }, - }; - const custom = { - dms: false, - mentions: false, - threads: false, - needsAction: false, - agentReplies: true, - dueReminders: false, - drafts: false, - }; - - assert.equal( - matchesActivityCustomView(item, custom, new Set(["owned"])), - true, - ); - assert.equal( - matchesActivityCustomView(item, custom, new Set(["other"])), - false, - ); -}); - // --- getReactionTargetId --- test("getReactionTargetId returns the last e-tag target id", () => { diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 738d10e70c..f4c478c375 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -4,7 +4,6 @@ import { type InboxFilter, type InboxItem, } from "@/features/home/lib/inbox"; -import type { ActivityCustomView } from "@/features/home/lib/activityViewPreferences"; import { getChannelIdFromTags, getThreadReference, @@ -25,13 +24,8 @@ function hasThreadReplyTags(tags: string[][]) { return thread.parentId !== null && !isBroadcastReply(tags); } -export function filterActivityInboxItems( - items: InboxItem[], - activityEnabled: boolean, -) { - return activityEnabled - ? items.filter((item) => item.item.kind !== KIND_REMINDER) - : items; +export function filterActivityInboxItems(items: InboxItem[]) { + return items.filter((item) => item.item.kind !== KIND_REMINDER); } export function hasInboxThreadContext( @@ -97,32 +91,6 @@ export function matchesActivityAllView( ); } -export function matchesActivityCustomView( - item: { - categories: readonly string[]; - groupItems?: readonly FeedItem[]; - item?: FeedItem; - }, - custom: ActivityCustomView, - ownedAgentPubkeys: ReadonlySet, -): boolean { - const representative = item.item ?? item.groupItems?.at(-1); - return ( - (custom.dms && representative?.channelType === "dm") || - (custom.mentions && item.categories.includes("mention")) || - (custom.threads && - [item.item, ...(item.groupItems ?? [])].some((groupItem) => - groupItem ? hasThreadReplyTags(groupItem.tags) : false, - )) || - (custom.needsAction && item.categories.includes("needs_action")) || - (custom.agentReplies && - Boolean( - representative && - ownedAgentPubkeys.has(normalizePubkey(representative.pubkey)), - )) - ); -} - export function getContextMessageDepth( event: RelayEvent, eventById: ReadonlyMap, diff --git a/desktop/src/features/home/ui/ActivityCustomViewDialog.tsx b/desktop/src/features/home/ui/ActivityCustomViewDialog.tsx deleted file mode 100644 index df18adac4c..0000000000 --- a/desktop/src/features/home/ui/ActivityCustomViewDialog.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import * as React from "react"; - -import type { ActivityCustomView } from "@/features/home/lib/activityViewPreferences"; -import { Button } from "@/shared/ui/button"; -import { Checkbox } from "@/shared/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; - -const SOURCE_OPTIONS: Array<{ - key: keyof ActivityCustomView; - label: string; -}> = [ - { key: "dms", label: "Direct messages" }, - { key: "mentions", label: "Mentions" }, - { key: "threads", label: "Threads" }, - { key: "agentReplies", label: "Agent replies" }, - { key: "needsAction", label: "Needs action" }, - { key: "dueReminders", label: "Due reminders" }, - { key: "drafts", label: "Drafts" }, -]; - -type ActivityCustomViewDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - onSave: (value: ActivityCustomView) => void; - value: ActivityCustomView; -}; - -export function ActivityCustomViewDialog({ - open, - onOpenChange, - onSave, - value, -}: ActivityCustomViewDialogProps) { - const [draft, setDraft] = React.useState(value); - - React.useEffect(() => { - if (open) setDraft(value); - }, [open, value]); - - const hasSelectedSource = SOURCE_OPTIONS.some(({ key }) => draft[key]); - const update = (key: keyof ActivityCustomView, checked: boolean) => { - setDraft((current) => ({ ...current, [key]: checked })); - }; - - return ( - - - - Custom view - - Choose what appears in your custom Activity view for this community. - - - -
- {SOURCE_OPTIONS.map((option) => { - const id = `activity-custom-${option.key}`; - return ( - - ); - })} -
- - - - - -
-
- ); -} diff --git a/desktop/src/features/home/ui/ActivityFilterMenu.tsx b/desktop/src/features/home/ui/ActivityFilterMenu.tsx index 035ceefa43..97e5e74103 100644 --- a/desktop/src/features/home/ui/ActivityFilterMenu.tsx +++ b/desktop/src/features/home/ui/ActivityFilterMenu.tsx @@ -1,36 +1,19 @@ -import { ChevronDown, Settings, Star } from "lucide-react"; -import * as React from "react"; +import { ChevronDown } from "lucide-react"; -import type { - ActivityCustomView, - ActivityViewId, -} from "@/features/home/lib/activityViewPreferences"; import type { InboxFilter } from "@/features/home/lib/inbox"; -import { ActivityCustomViewDialog } from "@/features/home/ui/ActivityCustomViewDialog"; import { cn } from "@/shared/lib/cn"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -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_FILTER_OPTIONS: Array<{ label: string; - value: ActivityViewId; + value: InboxFilter; }> = [ { value: "all", label: "All" }, { value: "mention", label: "Mentions" }, @@ -39,38 +22,29 @@ const ACTIVITY_FILTER_OPTIONS: Array<{ { value: "agent_activity", label: "Agents" }, { value: "reminders", label: "Reminders" }, { value: "drafts", label: "Drafts" }, - { value: "custom", label: "Custom" }, ]; 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 = { - activityEnabled: boolean; activeDraftCount: number; - customView: ActivityCustomView; - defaultView: ActivityViewId; dueReminderCount: number; filter: InboxFilter; - onCustomViewChange: (value: ActivityCustomView) => void; onFilterChange: (value: InboxFilter) => void; reminderCount: number; }; export function ActivityFilterMenu({ - activityEnabled, activeDraftCount, - customView, - defaultView, dueReminderCount, filter, - onCustomViewChange, onFilterChange, reminderCount, }: ActivityFilterMenuProps) { - const [editorOpen, setEditorOpen] = React.useState(false); - const options = activityEnabled ? ACTIVITY_FILTER_OPTIONS : FILTER_OPTIONS; - const activeFilter = options.find((option) => option.value === filter); + const activeFilter = ACTIVITY_FILTER_OPTIONS.find( + (option) => option.value === filter, + ); const statusLabel = dueReminderCount > 0 ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` @@ -79,105 +53,54 @@ export function ActivityFilterMenu({ : null; return ( - <> - - - - - - onFilterChange(value as InboxFilter)} - value={filter} - > - {options.map((option) => { - const radioItem = ( - - - {option.label} - - {activityEnabled && option.value === defaultView ? ( - - ) : null} - {option.value === "reminders" && - (activityEnabled ? reminderCount : dueReminderCount) > - 0 ? ( - - {activityEnabled ? reminderCount : dueReminderCount} - - ) : option.value === "drafts" && activeDraftCount > 0 ? ( - - {activeDraftCount} - - ) : null} - + + + + + + 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} - - ); - - if (!activityEnabled || option.value !== "custom") { - return radioItem; - } - - return ( -
- {radioItem} - setEditorOpen(true)} - title="Edit Custom view" - > - - -
- ); - })} - - - - - + + +
+ ))} +
+
+
); } diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index fd7fc4ef90..b6512816e3 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -4,7 +4,6 @@ import { useAppShell } from "@/app/AppShellContext"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; import type { HomeFeedResponse } from "@/shared/api/types"; -import { useFeatureEnabled } from "@/shared/features"; import { isRelayUnreachableError, RELAY_UNREACHABLE_MESSAGE, @@ -26,7 +25,6 @@ export function HomeScreen({ onOpenContext, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); - const activityEnabled = useFeatureEnabled("activity"); const { threadActivityFeedItems } = useAppShell(); const augmentedFeed = React.useMemo((): HomeFeedResponse | undefined => { @@ -50,7 +48,6 @@ export function HomeScreen({ return (
0 && homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; - const { - filter, - preferences: activityViewPreferences, - setCustomView, - setDefaultView, - setFilter, - setUnreadOnly, - unreadOnly, - } = useActivityInboxFilter(activityEnabled, currentPubkey); + const [filter, setFilter] = React.useState("all"); + const [unreadOnly, setUnreadOnly] = React.useState(false); // Explicit selections are mirrored to the URL (`?item=`), so back/forward // restores the detail pane each history entry was showing and reloads // restore it from the URL. Default/automatic selection stays local-only — @@ -127,8 +117,7 @@ export function HomeView({ const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; const isMessagesMode = !isReminders && !isDrafts; - const allowMixedPersonalSelection = - activityEnabled && (filter === "all" || filter === "custom"); + const allowMixedPersonalSelection = filter === "all"; const { drafts: { activeCount: activeDraftCount, @@ -146,7 +135,6 @@ export function HomeView({ select: setSelectedReminderId, }, } = useHomePersonalActivity({ - activityEnabled, allowMixedSelection: allowMixedPersonalSelection, currentPubkey, isDrafts, @@ -334,7 +322,7 @@ export function HomeView({ }); const feedProfiles = feedProfilesQuery.data?.profiles; const ownedAgentPubkeys = useOwnedAgentPubkeys( - activityEnabled, + true, feedProfiles, currentPubkey, ); @@ -375,9 +363,8 @@ export function HomeView({ getThreadReadAt, profiles: feedProfiles, }); - return filterActivityInboxItems(items, activityEnabled); + return filterActivityInboxItems(items); }, [ - activityEnabled, channels, currentPubkey, feed, @@ -431,64 +418,35 @@ export function HomeView({ const filteredItems = React.useMemo(() => { return inboxItems.filter( (item) => - (activityEnabled && filter === "custom" - ? matchesActivityCustomView( - item, - activityViewPreferences.custom, - ownedAgentPubkeys, - ) - : matchesInboxFilter( - item, - filter, - activityEnabled ? ownedAgentPubkeys : undefined, - )) && + matchesInboxFilter(item, filter, ownedAgentPubkeys) && (!unreadOnly || !effectiveDoneSet.has(item.id) || item.conversationId === selectedConversationId), ); }, [ - activityViewPreferences.custom, effectiveDoneSet, - activityEnabled, 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; 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, - ]); + }, [filteredItems, selectedConversationId, selectedEventId]); const unreadBoundaryEventId = React.useMemo(() => { if (!selectedItem) return null; if (unreadBoundary?.conversationId === selectedItem.conversationId) { @@ -538,17 +496,49 @@ export function HomeView({ 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); - handleUserSelectItem(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); }, [ - handleUserSelectItem, - setFilter, + applyInboxSearchPatch, + effectiveDoneSet, + inboxItems, + isNarrowHomeViewport, + ownedAgentPubkeys, + selectedConversationId, setSelectedDraftKey, setSelectedReminderId, + unreadOnly, ], ); @@ -601,7 +591,6 @@ export function HomeView({ showDetailPane, showListPane, } = getHomePaneLayout({ - activityEnabled, hasAuxiliaryPane, homeWidthPx: homeInboxWidthPx, inboxListWidthPx, @@ -651,20 +640,15 @@ export function HomeView({ {showListPane ? ( ) : null}
-

- - {contextLabel} - -

+ {canOpenChannel && contextChannelId ? ( +

+ +

+ ) : ( +

+ + {contextLabel} + +

+ )}
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 8828c51080..b0020b16c0 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -5,7 +5,6 @@ import { ExternalLink, FileText, MailOpen, - Star, } from "lucide-react"; import * as React from "react"; @@ -16,10 +15,6 @@ import { type InboxTypeLabel, } from "@/features/home/lib/inbox"; import { buildActivityListRows } from "@/features/home/lib/activityListRows"; -import type { - ActivityCustomView, - ActivityViewId, -} from "@/features/home/lib/activityViewPreferences"; import { ActivityFilterMenu } from "@/features/home/ui/ActivityFilterMenu"; import { DraftsPanel, @@ -60,11 +55,9 @@ const ACTIVITY_EMPTY_STATE_TITLES: Record = { mention: "No mentions found", thread: "No threads found", needs_action: "Nothing needs action", - activity: "No activity found", agent_activity: "No agent updates found", reminders: "No reminders", drafts: "No drafts", - custom: "No custom activity found", }; const ACTIVITY_UNREAD_EMPTY_STATE_TITLES: Record = { @@ -72,11 +65,9 @@ const ACTIVITY_UNREAD_EMPTY_STATE_TITLES: Record = { mention: "No unread mentions", thread: "No unread threads", needs_action: "No unread items needing action", - activity: "No unread activity", agent_activity: "No unread agent updates", reminders: "No unread reminders", drafts: "No unread drafts", - custom: "No unread custom activity", }; const INBOX_HEADER_ICON_BUTTON_CLASS = @@ -192,19 +183,14 @@ function PersonalItemRow({ } type InboxListPaneProps = { - activityEnabled: boolean; activeReminderEventIds?: ReadonlySet; agentPubkeys?: ReadonlySet; activeDraftCount: number; - customView: ActivityCustomView; - defaultView: ActivityViewId; draftItems: DraftViewItem[]; doneSet: ReadonlySet; filter: InboxFilter; items: InboxItem[]; onFilterChange: (filter: InboxFilter) => void; - onCustomViewChange: (value: ActivityCustomView) => void; - onDefaultViewChange: (value: ActivityViewId) => void; onDeleteDraft: (draftKey: string) => void; onMarkRead: (itemId: string) => void; onMarkUnread: (itemId: string) => void; @@ -225,19 +211,14 @@ type InboxListPaneProps = { }; export function InboxListPane({ - activityEnabled, activeReminderEventIds, agentPubkeys, activeDraftCount, - customView, - defaultView, draftItems, doneSet, filter, items, onFilterChange, - onCustomViewChange, - onDefaultViewChange, onDeleteDraft, onMarkRead, onMarkUnread, @@ -258,34 +239,27 @@ export function InboxListPane({ }: InboxListPaneProps) { const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; - const currentActivityView = activityEnabled - ? (filter as ActivityViewId) - : null; - const isMixedActivityView = - activityEnabled && (filter === "all" || filter === "custom"); - const includeDrafts = filter === "all" || customView.drafts; - const includeDueReminders = filter === "all" || customView.dueReminders; + const isMixedActivityView = filter === "all"; const scrollRef = React.useRef(null); const activityRows = React.useMemo( () => buildActivityListRows({ - drafts: unreadOnly || !includeDrafts ? [] : draftItems, + drafts: unreadOnly || !isMixedActivityView ? [] : draftItems, items, - reminders: - unreadOnly || !includeDueReminders - ? [] - : reminders.filter((reminder) => - isDue(reminder, Math.floor(Date.now() / 1_000)), - ), + reminders: unreadOnly + ? [] + : reminders.filter((reminder) => + isDue(reminder, Math.floor(Date.now() / 1_000)), + ), }), - [ - draftItems, - includeDrafts, - includeDueReminders, - items, - reminders, - unreadOnly, - ], + [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( @@ -301,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 = @@ -423,6 +401,15 @@ export function InboxListPane({ isDone={isDone} label={typeLabel} /> + {dueReminder ? ( +
+ + Reminder due +
+ ) : null}
- - - ) : null}
- {activityEnabled ? "Show unread only" : "Show unread"} + Show unread only
@@ -626,9 +595,8 @@ export function InboxListPane({ > {reminderPubkey ? ( @@ -652,13 +620,15 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > - {isMixedActivityView && activityRows.length > 0 ? ( + {visibleActivityRows.length > 0 ? ( row.key} - items={activityRows} + items={visibleActivityRows} renderItem={(row) => { - if (row.kind === "inbox") return renderItem(row.item); + if (row.kind === "inbox") { + return renderItem(row.item, row.dueReminder); + } if (row.kind === "reminder") { const source = reminderSources.get(row.reminder.id); @@ -716,39 +686,23 @@ export function InboxListPane({ }} scrollRef={scrollRef} /> - ) : items.length === 0 ? ( + ) : (

- {activityEnabled - ? unreadOnly - ? ACTIVITY_UNREAD_EMPTY_STATE_TITLES[filter] - : ACTIVITY_EMPTY_STATE_TITLES[filter] - : unreadOnly - ? "No unread messages" - : "No messages found"} + {unreadOnly + ? ACTIVITY_UNREAD_EMPTY_STATE_TITLES[filter] + : ACTIVITY_EMPTY_STATE_TITLES[filter]}

- {activityEnabled - ? unreadOnly - ? "Turn off Show unread only to see read activity." - : filter === "all" - ? "New activity will appear here." - : "Switch back to All to see other activity." - : unreadOnly - ? "Turn off the unread filter to see read messages." - : "Switch back to all mail to see more messages."} + {unreadOnly + ? "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/useActivityInboxFilter.ts b/desktop/src/features/home/useActivityInboxFilter.ts deleted file mode 100644 index f62120dab8..0000000000 --- a/desktop/src/features/home/useActivityInboxFilter.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as React from "react"; - -import { useCommunities } from "@/features/communities/useCommunities"; -import { - type ActivityCustomView, - type ActivityViewId, - DEFAULT_ACTIVITY_VIEW_PREFERENCES, - readActivityViewPreferences, - writeActivityViewPreferences, -} from "@/features/home/lib/activityViewPreferences"; -import type { InboxFilter } from "@/features/home/lib/inbox"; - -export function useActivityInboxFilter( - activityEnabled: boolean, - currentPubkey?: string, -) { - const { activeCommunity } = useCommunities(); - const relayUrl = activeCommunity?.relayUrl; - const [preferences, setPreferences] = React.useState( - DEFAULT_ACTIVITY_VIEW_PREFERENCES, - ); - const [filter, setFilter] = React.useState( - activityEnabled ? preferences.defaultView : "all", - ); - const [unreadOnly, setUnreadOnly] = React.useState(false); - React.useEffect(() => { - const next = currentPubkey - ? readActivityViewPreferences(currentPubkey, relayUrl) - : DEFAULT_ACTIVITY_VIEW_PREFERENCES; - setPreferences(next); - setFilter(activityEnabled ? next.defaultView : "all"); - }, [activityEnabled, currentPubkey, relayUrl]); - - React.useEffect(() => { - if (activityEnabled && filter === "activity") setFilter("all"); - if (!activityEnabled && filter === "custom") setFilter("all"); - }, [activityEnabled, filter]); - - const persist = React.useCallback( - (next: typeof preferences) => { - setPreferences(next); - if (currentPubkey) { - writeActivityViewPreferences(currentPubkey, next, relayUrl); - } - }, - [currentPubkey, relayUrl], - ); - - const setDefaultView = React.useCallback( - (defaultView: ActivityViewId) => persist({ ...preferences, defaultView }), - [persist, preferences], - ); - - const setCustomView = React.useCallback( - (custom: ActivityCustomView) => persist({ ...preferences, custom }), - [persist, preferences], - ); - - return { - filter, - preferences, - setCustomView, - setDefaultView, - setFilter, - setUnreadOnly, - unreadOnly, - }; -} diff --git a/desktop/src/features/home/useHomePersonalActivity.ts b/desktop/src/features/home/useHomePersonalActivity.ts index 6f3d263902..e8b2a937e0 100644 --- a/desktop/src/features/home/useHomePersonalActivity.ts +++ b/desktop/src/features/home/useHomePersonalActivity.ts @@ -8,7 +8,6 @@ import { import { groupReminders } from "@/features/reminders/lib/reminderFilters"; type UseHomePersonalActivityOptions = { - activityEnabled: boolean; allowMixedSelection: boolean; currentPubkey?: string; isDrafts: boolean; @@ -18,7 +17,6 @@ type UseHomePersonalActivityOptions = { }; export function useHomePersonalActivity({ - activityEnabled, allowMixedSelection, currentPubkey, isDrafts, @@ -43,8 +41,7 @@ export function useHomePersonalActivity({ null; React.useEffect(() => { - const selectionEnabled = - activityEnabled && (isReminders || allowMixedSelection); + const selectionEnabled = isReminders || allowMixedSelection; if (!selectionEnabled) { selectReminder(null); return; @@ -62,7 +59,6 @@ export function useHomePersonalActivity({ isNarrowHomeViewport ? null : (pendingReminders[0]?.id ?? null), ); }, [ - activityEnabled, allowMixedSelection, isNarrowHomeViewport, isReminders, @@ -75,7 +71,7 @@ export function useHomePersonalActivity({ const drafts = useHomeDrafts({ autoSelect: isDrafts, isNarrowHomeViewport, - selectionEnabled: isDrafts || (activityEnabled && allowMixedSelection), + selectionEnabled: isDrafts || allowMixedSelection, viewportWidthPx, }); diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index e18a6bf333..b656b24dcf 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,7 +1,7 @@ -import { Activity, Bell, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; +import { Activity, Bell, Bot, FolderGit2, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; -import { FeatureGate, useFeatureEnabled } from "@/shared/features"; +import { FeatureGate } from "@/shared/features"; import type { Channel, SearchHit } from "@/shared/api/types"; import { SidebarHeader, @@ -89,10 +89,6 @@ export function AppSidebarPrimaryMenu({ onSelectWorkflows, selectedView, }: AppSidebarPrimaryMenuProps) { - const activityEnabled = useFeatureEnabled("activity"); - const HomeIcon = activityEnabled ? Bell : Inbox; - const homeLabel = activityEnabled ? "Activity" : "Inbox"; - return ( - - {homeLabel} + + Activity {homeBadgeCount > 0 ? ( { - await installMockBridge(page); - await page.goto("/"); - - await expectHomeLabel(page, "Inbox"); - await openHomeFilters(page); - await expect( - page.getByRole("menuitemradio", { name: "Activity", exact: true }), - ).toBeVisible(); - await page.keyboard.press("Escape"); - - await openExperiments(page); - const activityToggle = page.getByTestId("feature-toggle-activity"); - await expect(activityToggle).not.toBeChecked(); - await activityToggle.click(); - await expect(activityToggle).toBeChecked(); - await page.getByTestId("settings-back-to-app").click(); - - await expectHomeLabel(page, "Activity"); - await openHomeFilters(page); - await expect( - page.getByRole("menuitemradio", { name: "Activity", exact: true }), - ).toHaveCount(0); - await expect( - page.getByRole("menuitemradio", { name: "Agents", exact: true }), - ).toBeVisible(); - await page.keyboard.press("Escape"); - - await openExperiments(page); - await page.getByTestId("feature-toggle-activity").click(); - await page.getByTestId("settings-back-to-app").click(); - await expectHomeLabel(page, "Inbox"); -}); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 20b21baacf..4c6059e136 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -8,7 +8,6 @@ import { openCreateChannelDialog, openNewMessagePage, } from "../helpers/bridge"; -import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; @@ -2570,63 +2569,7 @@ async function seedHomeInboxMention( await page.getByTestId(`home-inbox-item-${itemId}`).click(); } -test("Activity saves a Custom mix and community default", async ({ page }) => { - await page.addInitScript((key) => { - const overrides = JSON.parse( - window.localStorage.getItem(key) ?? "{}", - ) as Record; - window.localStorage.setItem( - key, - JSON.stringify({ ...overrides, activity: true }), - ); - }, FEATURE_OVERRIDES_STORAGE_KEY); - await page.goto("/"); - - await page.getByTestId("inbox-filter-trigger").click(); - await page.getByRole("menuitem", { name: "Edit Custom view" }).click(); - const dialog = page.getByRole("dialog", { name: "Custom view" }); - await expect(dialog).toBeVisible(); - await dialog.getByRole("checkbox", { name: "Agent replies" }).uncheck(); - await dialog.getByRole("button", { name: "Save" }).click(); - - await page.getByTestId("inbox-filter-trigger").click(); - await page.getByRole("menuitemradio", { name: "Custom" }).click(); - await expect(page.getByTestId("inbox-filter-trigger")).toContainText( - "Custom", - ); - await expect(page.getByRole("menuitemradio", { name: "Custom" })).toHaveCount( - 0, - ); - await page.getByTestId("inbox-options-trigger").click(); - const setDefault = page.getByRole("button", { - name: "Set as default", - }); - await expect(setDefault).toBeVisible(); - await setDefault.click(); - - await page.reload(); - await expect(page.getByTestId("inbox-filter-trigger")).toContainText( - "Custom", - ); - await page.getByTestId("inbox-filter-trigger").click(); - await page.getByRole("menuitem", { name: "Edit Custom view" }).click(); - await expect( - page - .getByRole("dialog", { name: "Custom view" }) - .getByRole("checkbox", { name: "Agent replies" }), - ).not.toBeChecked(); -}); - test("Activity All excludes generic channel traffic", async ({ page }) => { - await page.addInitScript((key) => { - const overrides = JSON.parse( - window.localStorage.getItem(key) ?? "{}", - ) as Record; - window.localStorage.setItem( - key, - JSON.stringify({ ...overrides, activity: true }), - ); - }, FEATURE_OVERRIDES_STORAGE_KEY); await page.goto("/"); await page.waitForFunction(() => { const win = window as MockFeedWindow; @@ -2687,15 +2630,7 @@ test("Activity unread-only hides reminders and drafts from mixed All", async ({ }) => { const draftKey = `channel:${GENERAL_CHANNEL_ID}`; await page.addInitScript( - ({ draftStoreKey, draftStorageKey, featureKey }) => { - const overrides = JSON.parse( - window.localStorage.getItem(featureKey) ?? "{}", - ) as Record; - window.localStorage.setItem( - featureKey, - JSON.stringify({ ...overrides, activity: true }), - ); - + ({ draftStoreKey, draftStorageKey }) => { const timestamp = new Date().toISOString(); window.localStorage.setItem( draftStoreKey, @@ -2717,7 +2652,6 @@ test("Activity unread-only hides reminders and drafts from mixed All", async ({ { draftStorageKey: draftKey, draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`, - featureKey: FEATURE_OVERRIDES_STORAGE_KEY, }, ); await page.goto("/"); @@ -2806,18 +2740,71 @@ test("Activity unread-only hides reminders and drafts from mixed All", async ({ await expect(draftRow).toHaveCount(0); }); +test("Activity merges a due reminder into its represented conversation", async ({ + page, +}) => { + const messageId = "activity-reminder-merge-message"; + const reminderId = "activity-reminder-merge"; + await seedHomeInboxMention(page, messageId); + + await page.evaluate( + async ({ + authorPubkey, + channelId, + messageId: targetEventId, + pubkey, + reminderId: id, + }) => { + const now = Math.floor(Date.now() / 1_000); + window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([ + { + id, + pubkey, + created_at: now - 600, + kind: 30300, + tags: [ + ["d", id], + ["not_before", String(now - 60)], + ], + content: JSON.stringify({ + target: { + eventId: targetEventId, + channelId, + preview: "Please review the home panel routing.", + authorPubkey, + }, + status: "pending", + }), + sig: "mocksig".repeat(20).slice(0, 128), + }, + ]); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["reminders"], + }); + }, + { + authorPubkey: TEST_IDENTITIES.alice.pubkey, + channelId: GENERAL_CHANNEL_ID, + messageId, + pubkey: MOCK_IDENTITY_PUBKEY, + reminderId, + }, + ); + + const conversationRow = page.getByTestId(`home-inbox-item-${messageId}`); + await expect(conversationRow.getByText("Reminder due")).toBeVisible(); + await expect( + page.getByTestId(`home-all-reminders-${reminderId}`), + ).toHaveCount(0); + + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Mentions" }).click(); + await expect(conversationRow.getByText("Reminder due")).toBeVisible(); +}); + test("Activity All keeps its filter when opening a due reminder", async ({ page, }) => { - await page.addInitScript((key) => { - const overrides = JSON.parse( - window.localStorage.getItem(key) ?? "{}", - ) as Record; - window.localStorage.setItem( - key, - JSON.stringify({ ...overrides, activity: true }), - ); - }, FEATURE_OVERRIDES_STORAGE_KEY); await page.goto("/"); await expect(page.getByTestId("home-inbox")).toBeVisible(); @@ -2870,16 +2857,8 @@ test("Activity All keeps its filter when opening a due reminder", async ({ test("Activity reminder rows and detail identify DM context", async ({ page, }) => { - await page.addInitScript((key) => { - const overrides = JSON.parse( - window.localStorage.getItem(key) ?? "{}", - ) as Record; - window.localStorage.setItem( - key, - JSON.stringify({ ...overrides, activity: true }), - ); - }, FEATURE_OVERRIDES_STORAGE_KEY); await page.goto("/"); + await expect(page.getByTestId("home-inbox")).toBeVisible(); const reminderId = "activity-dm-reminder"; const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; @@ -2920,10 +2899,6 @@ test("Activity reminder rows and detail identify DM context", async ({ }, ); - await expect( - page.getByTestId(`home-all-reminders-${reminderId}`), - ).toContainText("In DM with alice-tyler"); - await page.getByTestId("inbox-filter-trigger").click(); await page.getByRole("menuitemradio", { name: "Reminders" }).click(); const reminderRow = page.getByTestId(`home-reminder-item-${reminderId}`); @@ -2941,14 +2916,17 @@ test("Activity reminder rows and detail identify DM context", async ({ ); }); -test("home inbox source action navigates to the channel message", async ({ +test("Activity detail title and source action navigate to the conversation", async ({ page, }) => { await seedHomeInboxMention(page, "mock-feed-home-channel-navigate"); const detail = page.getByTestId("home-inbox-detail"); await expect(detail.getByRole("heading")).toHaveText("Message in #general"); - await detail.getByRole("button", { name: "Open in channel" }).click(); + await expect( + detail.getByRole("button", { name: "Open in channel" }), + ).toBeVisible(); + await detail.getByTestId("home-inbox-context-title").click(); await expect(page).toHaveURL( new RegExp(`#/channels/${GENERAL_CHANNEL_ID}\\?`), @@ -2983,18 +2961,64 @@ test("home inbox thread reply mention carries threadRootId to the channel", asyn await expect(page.getByTestId("home-inbox-list")).toHaveCount(0); }); +test("Activity filter changes preserve valid detail and directly select a replacement", async ({ + page, +}) => { + const threadItemId = "activity-filter-thread"; + const actionItemId = "activity-filter-action"; + await seedHomeInboxMention(page, threadItemId, [ + ["e", "activity-filter-root", "", "root"], + ["e", "activity-filter-parent", "", "reply"], + ["p", TEST_IDENTITIES.tyler.pubkey], + ]); + + await page.evaluate( + ({ actionId, channelId, senderPubkey }) => { + const pushFeedItem = (window as MockFeedWindow) + .__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!pushFeedItem) throw new Error("Mock feed helper is not installed."); + pushFeedItem({ + category: "needs_action", + channel_id: channelId, + channel_name: "general", + channel_type: "stream", + content: "Approve the replacement selection", + created_at: Math.floor(Date.now() / 1_000) + 120, + id: actionId, + kind: 46010, + pubkey: senderPubkey, + tags: [["h", channelId]], + }); + }, + { + actionId: actionItemId, + channelId: GENERAL_CHANNEL_ID, + senderPubkey: TEST_IDENTITIES.alice.pubkey, + }, + ); + + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Threads" }).click(); + await expect( + page.getByTestId(`home-inbox-item-${threadItemId}`), + ).toHaveAttribute("aria-current", "true"); + await expect(page.getByTestId("home-inbox-detail")).toContainText( + "Please review the home panel routing.", + ); + + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Needs action" }).click(); + await expect( + page.getByTestId(`home-inbox-item-${actionItemId}`), + ).toHaveAttribute("aria-current", "true"); + await expect(page.getByTestId("home-inbox-detail")).toContainText( + "Approve the replacement selection", + ); +}); + test("Activity keeps the unread boundary for replies from multiple agents", async ({ page, }) => { - await page.addInitScript((key) => { - const overrides = JSON.parse( - window.localStorage.getItem(key) ?? "{}", - ) as Record; - window.localStorage.setItem( - key, - JSON.stringify({ ...overrides, activity: true }), - ); - }, FEATURE_OVERRIDES_STORAGE_KEY); await page.goto("/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction(() => { diff --git a/desktop/tests/e2e/drafts-screenshots.spec.ts b/desktop/tests/e2e/drafts-screenshots.spec.ts index ccf8dc35cb..1a8fba9d5a 100644 --- a/desktop/tests/e2e/drafts-screenshots.spec.ts +++ b/desktop/tests/e2e/drafts-screenshots.spec.ts @@ -345,13 +345,11 @@ test.describe("drafts screenshots", () => { ); }); - test("06 — active-draft badge on inbox trigger and filter option", async ({ + test("06 — active-draft count appears only on the Drafts filter", async ({ page, }) => { - // Captures both badge placements for the PR screenshot: - // 1. The status dot on the inbox filter trigger button. - // 2. The badge next to "Drafts" in the filter dropdown. - // Two active drafts are seeded so the count is 2. + // Draft counts stay beside the Drafts option instead of decorating the + // overall Activity filter trigger. Two drafts make the count explicit. await installMockBridge(page); await patchCommunityPubkey(page); await seedDraftStore(page, ACTIVE_DRAFTS); @@ -361,13 +359,10 @@ test.describe("drafts screenshots", () => { timeout: 10_000, }); - // The trigger uses a compact dot while retaining the count accessibly. - const triggerBadge = page.getByTestId("inbox-draft-badge"); - await expect(triggerBadge).toBeVisible({ timeout: 6_000 }); - await expect(triggerBadge).toBeEmpty(); + await expect(page.getByTestId("inbox-draft-badge")).toHaveCount(0); await expect(page.getByTestId("inbox-filter-trigger")).toHaveAttribute( "aria-label", - "Filter inbox: All. 2 active drafts", + "Filter activity: All. 2 active drafts", ); // Open the filter dropdown so the badge-option is visible too. @@ -378,7 +373,7 @@ test.describe("drafts screenshots", () => { await waitForAnimations(page); - // Capture the full inbox header area including the open dropdown. + // Capture the Activity header and the dropdown-only count. await page.getByTestId("home-inbox").screenshot({ path: `${SHOTS}/06-draft-badge.png`, }); diff --git a/desktop/tests/e2e/reminders.spec.ts b/desktop/tests/e2e/reminders.spec.ts index fbdeebe43f..f60e224389 100644 --- a/desktop/tests/e2e/reminders.spec.ts +++ b/desktop/tests/e2e/reminders.spec.ts @@ -162,7 +162,11 @@ test.describe("reminders", () => { ]); await openRemindersFilter(page); - await expect(page.getByText("Follow up on this message")).toBeVisible(); + await expect( + page + .getByTestId("home-reminder-item-rem-active-01") + .getByText("Follow up on this message"), + ).toBeVisible(); await waitForAnimations(page); }); @@ -211,8 +215,16 @@ test.describe("reminders", () => { ]); await openRemindersFilter(page); - await expect(page.getByText("Reply to Alice")).toBeVisible(); - await expect(page.getByRole("heading", { name: "Overdue" })).toBeVisible(); + await expect( + page + .getByTestId("home-reminder-item-rem-overdue-01") + .getByText("Reply to Alice"), + ).toBeVisible(); + await expect( + page + .getByTestId("home-reminder-item-rem-overdue-01") + .getByText("2h overdue"), + ).toBeVisible(); await waitForAnimations(page); }); }); @@ -328,9 +340,17 @@ test.describe("reminders phase 2 — author, source, navigation", () => { ]); await openRemindersFilter(page); - // The reminder row body is a button whose preview text is the target - // message preview; clicking it navigates to the message in its channel. - await page.getByText("Reply to Alice").click(); + // Selecting a reminder keeps the list visible and opens its detail pane. + // Navigation is an explicit action from that detail. + await page + .getByTestId("home-reminder-item-rem-phase2-nav-01") + .getByRole("button") + .first() + .click(); + await page + .getByTestId("home-reminder-detail") + .getByRole("button", { name: "Open message" }) + .click(); // Lands in the #general chat view with the target message in context. await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d5ae2046db..eb236bae5e 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -2,9 +2,6 @@ import { expect, test } from "@playwright/test"; import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge"; -const DEFAULT_AGENT_ACTIVITY_PUBKEY = - "db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36"; - async function getTimelineMetrics(page: import("@playwright/test").Page) { return page.getByTestId("message-timeline").evaluate((element) => { const timeline = element as HTMLDivElement; @@ -72,12 +69,12 @@ async function expectHomeView(page: import("@playwright/test").Page) { async function selectHomeInboxFilter( page: import("@playwright/test").Page, - label: "Activity" | "Agents", + label: "Agents", ) { await page .getByTestId("home-inbox") .getByRole("button", { - name: /^Filter inbox:/, + name: /^Filter activity:/, }) .click(); await page.getByRole("menuitemradio", { name: label }).click(); @@ -298,77 +295,23 @@ test("opens a mocked channel from the inbox feed", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("general"); }); -test("inbox feed shows channel and agent activity sections", async ({ +test("Activity excludes generic channel and unowned agent traffic", async ({ page, }) => { const inboxList = page.getByTestId("home-inbox-list"); await page.goto("/"); + await expectHomeView(page); - await selectHomeInboxFilter(page, "Activity"); - await expect(inboxList).toContainText( + await expect(inboxList).not.toContainText( "Engineering shipped the desktop build.", ); - - await selectHomeInboxFilter(page, "Agents"); - await expect(inboxList).toContainText( + await expect(inboxList).not.toContainText( "Agent progress: channel index complete.", ); - await inboxList.getByText("Agent progress: channel index complete.").click(); - await expect(page.getByTestId("home-inbox-detail")).toContainText( - "Agent progress: channel index complete.", - ); -}); - -test("inbox agent hover hides actions without agent access", async ({ - page, -}) => { - await page.goto("/"); await selectHomeInboxFilter(page, "Agents"); - const agentRow = page.getByTestId("home-inbox-item-mock-feed-agent"); - await expect(agentRow).toContainText( - "Agent progress: channel index complete.", - ); - - await agentRow.getByTestId("home-inbox-avatar-mock-feed-agent").hover(); - const profilePopover = page.locator( - '[data-testid="user-profile-popover"][data-state="open"]', - ); - await expect(profilePopover).toBeVisible(); - await expect( - profilePopover.getByTestId( - `user-profile-popover-message-${DEFAULT_AGENT_ACTIVITY_PUBKEY}`, - ), - ).toHaveCount(0); - await expect( - profilePopover.getByTestId( - `user-profile-popover-wave-${DEFAULT_AGENT_ACTIVITY_PUBKEY}`, - ), - ).toHaveCount(0); - await expect( - profilePopover.getByTestId( - `user-profile-popover-huddle-${DEFAULT_AGENT_ACTIVITY_PUBKEY}`, - ), - ).toHaveCount(0); -}); - -test("opens a mocked forum activity item from the inbox feed", async ({ - page, -}) => { - await page.goto("/"); - - await selectHomeInboxFilter(page, "Activity"); - await expect(page.getByTestId("home-inbox-list")).toContainText( - "Engineering shipped the desktop build.", - ); - await page - .getByTestId("home-inbox-list") - .getByText("Engineering shipped the desktop build.") - .click(); - await expect(page.getByTestId("home-inbox-detail")).toContainText( - "Engineering shipped the desktop build.", - ); + await expect(inboxList).toContainText("No agent updates found"); }); test("inbox feed renders resolved author labels", async ({ page }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 88216d767f..31e8e50bfa 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -692,7 +692,7 @@ async function seedPreviewFeaturesEnabled(page: Page) { await page.addInitScript( ({ key, ids }) => { const overrides: Record = {}; - for (const id of ids) overrides[id] = id !== "activity"; + for (const id of ids) overrides[id] = true; window.localStorage.setItem(key, JSON.stringify(overrides)); }, { key: FEATURE_OVERRIDES_STORAGE_KEY, ids: PREVIEW_FEATURE_IDS }, diff --git a/preview-features.json b/preview-features.json index d0a7f2ee74..388f1c39b0 100644 --- a/preview-features.json +++ b/preview-features.json @@ -30,12 +30,6 @@ "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", "platforms": ["desktop"] - }, - { - "id": "activity", - "name": "Activity", - "description": "A redesigned inbox for threads, agent updates, reminders, and drafts", - "platforms": ["desktop"] } ] }