From be33211d2c8873818b9af4b93745d45b6ede5916 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 14 Apr 2026 17:14:33 -0400 Subject: [PATCH 01/15] feat(desktop): redesign home as an inbox Turn the home feed into a mail-style inbox so mentions and action items feel easier to triage. Keep inbox replies visible inline and align the thread, composer, and header chrome with the updated desktop layout. Made-with: Cursor --- desktop/src/app/routes/index.tsx | 59 +-- desktop/src/features/chat/ui/ChatHeader.tsx | 4 +- desktop/src/features/home/lib/inbox.ts | 235 ++++++++++ desktop/src/features/home/ui/HomeScreen.tsx | 32 +- desktop/src/features/home/ui/HomeView.tsx | 425 ++++++++++-------- .../src/features/home/ui/InboxDetailPane.tsx | 343 ++++++++++++++ .../src/features/home/ui/InboxListPane.tsx | 164 +++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 8 +- desktop/tests/e2e/channels.spec.ts | 2 +- desktop/tests/e2e/integration.spec.ts | 17 +- desktop/tests/e2e/profile.spec.ts | 14 +- desktop/tests/e2e/smoke.spec.ts | 22 +- desktop/tests/e2e/stream.spec.ts | 41 +- 13 files changed, 1053 insertions(+), 313 deletions(-) create mode 100644 desktop/src/features/home/lib/inbox.ts create mode 100644 desktop/src/features/home/ui/InboxDetailPane.tsx create mode 100644 desktop/src/features/home/ui/InboxListPane.tsx diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 2a111bc88..753d87607 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -2,56 +2,15 @@ import { createFileRoute } from "@tanstack/react-router"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; -import type { FeedItem, SearchHit } from "@/shared/api/types"; import { useIdentityQuery } from "@/shared/api/hooks"; import { HomeScreen } from "@/features/home/ui/HomeScreen"; -import { - KIND_FORUM_COMMENT, - KIND_FORUM_POST, - KIND_JOB_ACCEPTED, - KIND_JOB_CANCEL, - KIND_JOB_ERROR, - KIND_JOB_PROGRESS, - KIND_JOB_REQUEST, - KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_V2, -} from "@/shared/constants/kinds"; - -function canOpenFeedItemAsExactEvent(item: FeedItem) { - return ( - item.kind === KIND_STREAM_MESSAGE || - item.kind === KIND_STREAM_MESSAGE_V2 || - item.kind === KIND_JOB_REQUEST || - item.kind === KIND_JOB_ACCEPTED || - item.kind === KIND_JOB_PROGRESS || - item.kind === KIND_JOB_RESULT || - item.kind === KIND_JOB_CANCEL || - item.kind === KIND_JOB_ERROR || - item.kind === KIND_FORUM_POST || - item.kind === KIND_FORUM_COMMENT - ); -} - -function toFeedItemSearchHit(item: FeedItem): SearchHit { - return { - channelId: item.channelId, - channelName: item.channelName || null, - content: item.content, - createdAt: item.createdAt, - eventId: item.id, - kind: item.kind, - pubkey: item.pubkey, - score: 0, - }; -} export const Route = createFileRoute("/")({ component: HomeRouteComponent, }); function HomeRouteComponent() { - const { goChannel, goPulse, openSearchHit } = useAppNavigation(); + const { goChannel } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); const channels = channelsQuery.data ?? []; @@ -61,20 +20,8 @@ function HomeRouteComponent() { { - if (!item.channelId) { - return; - } - - if (canOpenFeedItemAsExactEvent(item)) { - void openSearchHit(toFeedItemSearchHit(item)); - return; - } - - void goChannel(item.channelId); - }} - onOpenPulse={() => { - void goPulse(); + onOpenChannel={(channelId) => { + void goChannel(channelId); }} /> ); diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 1e1eb20a3..49dd369bb 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -4,7 +4,7 @@ import { CircleDot, FileText, Hash, - Home, + Inbox, Lock, Zap, } from "lucide-react"; @@ -32,7 +32,7 @@ function ChannelIcon({ mode?: "home" | "channel" | "agents" | "workflows" | "pulse"; }) { if (mode === "home") { - return ; + return ; } if (mode === "agents") { diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts new file mode 100644 index 000000000..97dfbe9de --- /dev/null +++ b/desktop/src/features/home/lib/inbox.ts @@ -0,0 +1,235 @@ +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; + +export type InboxFilter = "all" | "mention" | "needs_action"; + +export type InboxItem = { + avatarUrl: string | null; + id: string; + item: FeedItem; + categoryLabel: string; + channelLabel: string | null; + fullTimestampLabel: string; + isActionRequired: boolean; + mentionNames: string[]; + preview: string; + searchableText: string; + senderLabel: string; + subject: string; + timestampLabel: string; +}; + +export type InboxReply = { + authorLabel: string; + avatarUrl: string | null; + content: string; + fullTimestampLabel: string; + id: string; +}; + +export type InboxGroup = { + label: string; + items: InboxItem[]; +}; + +const listTimeFormatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", +}); + +const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +const shortDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +const weekdayFormatter = new Intl.DateTimeFormat("en-US", { + weekday: "long", +}); + +function startOfDay(value: Date) { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()); +} + +function diffInDays(from: Date, to: Date) { + return Math.round( + (startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000, + ); +} + +function feedHeadline(item: FeedItem) { + switch (item.kind) { + case 40007: + return "Reminder"; + case 43001: + return "Job requested"; + case 43002: + return "Job accepted"; + case 43003: + return "Progress update"; + case 43004: + return "Job result"; + case 43005: + return "Job cancelled"; + case 43006: + return "Job failed"; + case 45001: + return "Forum post"; + case 45003: + return "Forum reply"; + case 46010: + return "Approval requested"; + default: + if (item.category === "mention") { + return "Mention"; + } + + if (item.category === "agent_activity") { + return "Agent update"; + } + + return "Channel update"; + } +} + +function feedPreview(item: FeedItem) { + const content = item.content.trim(); + if (content.length > 0) { + return content; + } + + if (item.kind === 46010) { + return "A workflow is waiting for approval."; + } + + if (item.kind === 40007) { + return "A reminder is waiting for you."; + } + + return "No additional details were attached to this event."; +} + +function formatInboxTimestamp(unixSeconds: number) { + const date = new Date(unixSeconds * 1_000); + const now = new Date(); + const dayDiff = diffInDays(now, date); + + if (dayDiff === 0) { + return listTimeFormatter.format(date); + } + + if (dayDiff === 1) { + return "Yesterday"; + } + + if (now.getFullYear() === date.getFullYear()) { + return shortDateFormatter.format(date); + } + + return shortDateWithYearFormatter.format(date); +} + +export function formatInboxFullTimestamp(unixSeconds: number) { + return fullTimeFormatter.format(new Date(unixSeconds * 1_000)); +} + +export function groupInboxItems(items: InboxItem[]): InboxGroup[] { + const groups = new Map(); + const now = new Date(); + + for (const item of items) { + const date = new Date(item.item.createdAt * 1_000); + const dayDiff = diffInDays(now, date); + const label = + dayDiff === 0 + ? "Today" + : dayDiff === 1 + ? "Yesterday" + : dayDiff < 7 + ? weekdayFormatter.format(date) + : shortDateWithYearFormatter.format(date); + + const current = groups.get(label) ?? []; + current.push(item); + groups.set(label, current); + } + + return [...groups.entries()].map(([label, groupedItems]) => ({ + label, + items: groupedItems, + })); +} + +export function buildInboxItems({ + currentPubkey, + feed, + profiles, +}: { + currentPubkey?: string; + feed?: HomeFeedResponse; + profiles?: UserProfileLookup; +}): InboxItem[] { + if (!feed) { + return []; + } + + const items = [...feed.feed.mentions, ...feed.feed.needsAction].sort( + (left, right) => right.createdAt - left.createdAt, + ); + + return items.map((item) => { + const senderLabel = resolveUserLabel({ + pubkey: item.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const subject = feedHeadline(item); + const preview = feedPreview(item); + const mentionNames = resolveMentionNames(item.tags, profiles) ?? []; + const channelLabel = item.channelName.trim() || null; + const categoryLabel = + item.category === "needs_action" ? "Needs Action" : "Mention"; + + return { + avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null, + id: item.id, + item, + categoryLabel, + channelLabel, + fullTimestampLabel: formatInboxFullTimestamp(item.createdAt), + isActionRequired: item.category === "needs_action", + mentionNames, + preview, + searchableText: [ + senderLabel, + subject, + preview, + channelLabel ?? "", + categoryLabel, + ] + .join(" ") + .toLowerCase(), + senderLabel, + subject, + timestampLabel: formatInboxTimestamp(item.createdAt), + }; + }); +} diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index 86145c687..6f081241f 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -1,29 +1,44 @@ -import type { FeedItem } from "@/shared/api/types"; +import { RefreshCcw } from "lucide-react"; + import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; +import { Button } from "@/shared/ui/button"; type HomeScreenProps = { availableChannelIds: ReadonlySet; currentPubkey?: string; - onOpenFeedItem: (item: FeedItem) => void; - onOpenPulse: () => void; + onOpenChannel: (channelId: string) => void; }; export function HomeScreen({ availableChannelIds, currentPubkey, - onOpenFeedItem, - onOpenPulse, + onOpenChannel, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); return ( <> { + void homeFeedQuery.refetch(); + }} + type="button" + variant="outline" + > + + Refresh + + } + description="Personalized inbox for mentions, reminders, and approvals." mode="home" - title="Home" + title="Inbox" />
@@ -37,8 +52,7 @@ export function HomeScreen({ } feed={homeFeedQuery.data} isLoading={homeFeedQuery.isLoading} - onOpenFeedItem={onOpenFeedItem} - onOpenPulse={onOpenPulse} + onOpenChannel={onOpenChannel} onRefresh={() => { void homeFeedQuery.refetch(); }} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 38a67aa1d..aec499b93 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -1,69 +1,60 @@ import * as React from "react"; -import { Activity, AtSign, Bot, CircleAlert, RefreshCcw } from "lucide-react"; +import { RefreshCcw } from "lucide-react"; -import { useRelayAgentsQuery } from "@/features/agents/hooks"; +import { + type InboxFilter, + type InboxReply, + buildInboxItems, + formatInboxFullTimestamp, +} from "@/features/home/lib/inbox"; import { useFeedItemState } from "@/features/home/useFeedItemState"; +import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; +import { InboxListPane } from "@/features/home/ui/InboxListPane"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { useContactListQuery, useTimelineQuery } from "@/features/pulse/hooks"; -import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; -import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; +import type { HomeFeedResponse } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; -const FeedSection = React.lazy(async () => { - const module = await import("./FeedSection"); - return { default: module.FeedSection }; -}); - -const RecentNotesSection = React.lazy(async () => { - const module = await import("./RecentNotesSection"); - return { default: module.RecentNotesSection }; -}); - -type FeedFilter = - | "all" - | "mention" - | "needs_action" - | "activity" - | "agent_activity"; - function HomeLoadingState() { return ( -
-
-
- {["mentions", "actions"].map((section) => ( -
- -
- {["a", "b", "c"].map((row) => ( - - ))} -
-
- ))} +
+
+
+
+ + + +
+
+ {["a", "b", "c", "d"].map((row) => ( + + ))} +
+
+ +
+
+ + +
+
+ +
); } -const FILTER_OPTIONS: { value: FeedFilter; label: string }[] = [ - { value: "all", label: "All" }, - { value: "mention", label: "Mentions" }, - { value: "needs_action", label: "Needs Action" }, - { value: "activity", label: "Activity" }, - { value: "agent_activity", label: "Agent Updates" }, -]; - type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; errorMessage?: string; currentPubkey?: string; availableChannelIds: ReadonlySet; - onOpenFeedItem: (item: FeedItem) => void; - onOpenPulse: () => void; + onOpenChannel: (channelId: string) => void; onRefresh: () => void; }; @@ -73,64 +64,93 @@ export function HomeView({ errorMessage, currentPubkey, availableChannelIds, - onOpenFeedItem, - onOpenPulse, + onOpenChannel, onRefresh, }: HomeViewProps) { - const [filter, setFilter] = React.useState("all"); + const [filter, setFilter] = React.useState("all"); + const [searchValue, setSearchValue] = React.useState(""); + const [selectedItemId, setSelectedItemId] = React.useState(null); + const [isDeletingMessage, setIsDeletingMessage] = React.useState(false); + const [isSendingReply, setIsSendingReply] = React.useState(false); + const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< + Record + >({}); const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); - - // Defer Pulse widget queries until the shell is interactive - const startupReady = useDeferredStartup(); - const deferredPubkey = startupReady ? currentPubkey : undefined; - - // Recent notes for the Pulse widget - const contactListQuery = useContactListQuery(deferredPubkey); - const contactPubkeys = React.useMemo( - () => (contactListQuery.data?.contacts ?? []).map((c) => c.pubkey), - [contactListQuery.data], + const feedItems = React.useMemo( + () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), + [feed], ); - const notesPubkeys = React.useMemo( + const feedProfilePubkeys = React.useMemo( () => - deferredPubkey - ? [...new Set([deferredPubkey, ...contactPubkeys])] - : contactPubkeys, - [deferredPubkey, contactPubkeys], + [ + ...new Set([ + ...feedItems.map((item) => item.pubkey), + ...(currentPubkey ? [currentPubkey] : []), + ]), + ], + [currentPubkey, feedItems], ); - const notesTimelineQuery = useTimelineQuery( - notesPubkeys, - notesPubkeys.length > 0, - ); - const recentNotes = notesTimelineQuery.data?.notes?.slice(0, 5) ?? []; - const noteAuthorPubkeys = React.useMemo( - () => [...new Set(recentNotes.map((n) => n.pubkey))], - [recentNotes], + const feedProfilesQuery = useUsersBatchQuery( + feedProfilePubkeys, + { + enabled: feedProfilePubkeys.length > 0, + }, ); - const noteProfilesQuery = useUsersBatchQuery(noteAuthorPubkeys, { - enabled: noteAuthorPubkeys.length > 0, - }); - const noteProfiles = noteProfilesQuery.data?.profiles ?? {}; - const relayAgentsQuery = useRelayAgentsQuery({ enabled: startupReady }); - const agentPubkeySet = React.useMemo( - () => new Set((relayAgentsQuery.data ?? []).map((a) => a.pubkey)), - [relayAgentsQuery.data], + const feedProfiles = feedProfilesQuery.data?.profiles; + const inboxItems = React.useMemo( + () => + buildInboxItems({ + currentPubkey, + feed, + profiles: feedProfiles, + }), + [currentPubkey, feed, feedProfiles], ); + const filteredItems = React.useMemo(() => { + const normalizedQuery = searchValue.trim().toLowerCase(); - const feedItems = feed - ? [ - ...feed.feed.mentions, - ...feed.feed.needsAction, - ...(feed.feed.activity ?? []), - ...(feed.feed.agentActivity ?? []), - ] + return inboxItems.filter((item) => { + const matchesFilter = + filter === "all" ? true : item.item.category === filter; + const matchesQuery = + normalizedQuery.length === 0 || + item.searchableText.includes(normalizedQuery); + + return matchesFilter && matchesQuery; + }); + }, [filter, inboxItems, searchValue]); + const selectedItem = + filteredItems.find((item) => item.id === selectedItemId) ?? null; + const selectedItemReplies = selectedItem + ? localRepliesByItemId[selectedItem.id] ?? [] : []; - const feedProfilesQuery = useUsersBatchQuery( - feedItems.map((item) => item.pubkey), - { - enabled: feedItems.length > 0, + React.useEffect(() => { + if (filteredItems.length === 0) { + setSelectedItemId(null); + return; + } + + if (!filteredItems.some((item) => item.id === selectedItemId)) { + setSelectedItemId(filteredItems[0]?.id ?? null); + } + }, [filteredItems, selectedItemId]); + + React.useEffect(() => { + setIsDeletingMessage(false); + setIsSendingReply(false); + }, [selectedItemId]); + + const handleToggleDone = React.useCallback( + (itemId: string) => { + if (doneSet.has(itemId)) { + undoDone(itemId); + return; + } + + markDone(itemId); }, + [doneSet, markDone, undoDone], ); - const feedProfiles = feedProfilesQuery.data?.profiles; if (isLoading && !feed) { return ; @@ -138,9 +158,9 @@ export function HomeView({ if (!feed) { return ( -
-
-
+
+
+

Home feed unavailable

@@ -157,111 +177,124 @@ export function HomeView({ ); } - const showMentions = filter === "all" || filter === "mention"; - const showNeedsAction = filter === "all" || filter === "needs_action"; - const showActivity = filter === "all" || filter === "activity"; - const showAgentActivity = filter === "all" || filter === "agent_activity"; + const canReply = + selectedItem !== null && + selectedItem.item.channelId !== null && + availableChannelIds.has(selectedItem.item.channelId) && + 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(); + return ( -
-
-
- {FILTER_OPTIONS.map((option) => ( - - ))} -
+
+
+ - {recentNotes.length > 0 ? ( - - - - ) : null} + { + if (selectedItem) { + handleToggleDone(selectedItem.id); + } + }} + onDelete={() => { + if (!selectedItem || !canDelete) { + return; + } - -
- {showMentions ? ( - - ) : null} - {showNeedsAction ? ( - - ) : null} - {showActivity ? ( - - ) : null} - {showAgentActivity ? ( - - ) : null} -
-
+ setIsDeletingMessage(true); + void deleteMessage(selectedItem.id) + .then(() => { + onRefresh(); + }) + .finally(() => { + setIsDeletingMessage(false); + }); + }} + onOpenChannel={onOpenChannel} + onSendReply={async (content, mentionPubkeys, mediaTags) => { + const channelId = selectedItem?.item.channelId; + if (!selectedItem || !channelId || !canReply) { + throw new Error("Replies are not available for this item."); + } + + const itemToReply = selectedItem; + setIsSendingReply(true); + try { + const result = await sendChannelMessage( + channelId, + content, + itemToReply.id, + mediaTags, + mentionPubkeys, + ); + const authorPubkey = currentPubkey ?? itemToReply.item.pubkey; + const reply: InboxReply = { + authorLabel: currentPubkey + ? resolveUserLabel({ + currentPubkey, + profiles: feedProfiles, + pubkey: authorPubkey, + }) + : "You", + avatarUrl: + currentPubkey && feedProfiles + ? (feedProfiles[currentPubkey.trim().toLowerCase()]?.avatarUrl ?? + null) + : null, + content, + fullTimestampLabel: formatInboxFullTimestamp(result.createdAt), + id: result.eventId, + }; + setLocalRepliesByItemId((current) => ({ + ...current, + [itemToReply.id]: [...(current[itemToReply.id] ?? []), reply], + })); + onRefresh(); + } finally { + setIsSendingReply(false); + } + }} + onToggleDone={() => { + if (selectedItem) { + handleToggleDone(selectedItem.id); + } + }} + />
); diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx new file mode 100644 index 000000000..a808c63b3 --- /dev/null +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -0,0 +1,343 @@ +import { + ArrowUpRight, + Archive, + CheckCheck, + CircleDot, + Mail, + MailOpen, + Forward, + MoreHorizontal, + Reply, + ReplyAll, + Trash2, +} from "lucide-react"; +import * as React from "react"; + +import type { InboxItem, InboxReply } from "@/features/home/lib/inbox"; +import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type InboxDetailPaneProps = { + canDelete: boolean; + canOpenChannel: boolean; + canReply: boolean; + disabledReplyReason?: string | null; + isDone: boolean; + isDeletingMessage?: boolean; + isSendingReply?: boolean; + item: InboxItem | null; + localReplies?: InboxReply[]; + onArchive: () => void; + onDelete: () => void; + onOpenChannel: (channelId: string) => void; + onSendReply: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + onToggleDone: () => void; +}; + +export function InboxDetailPane({ + canDelete, + canOpenChannel, + canReply, + disabledReplyReason, + isDone, + isDeletingMessage = false, + isSendingReply = false, + item, + localReplies = [], + onArchive, + onDelete, + onOpenChannel, + onSendReply, + onToggleDone, +}: InboxDetailPaneProps) { + const detailPaneRef = React.useRef(null); + + const focusComposer = React.useCallback(() => { + window.requestAnimationFrame(() => { + const textarea = detailPaneRef.current?.querySelector( + '[data-testid="message-input"]', + ); + textarea?.focus(); + }); + }, []); + + if (!item) { + return ( +
+
+
+ +
+

Select a message

+

+ Pick an inbox item to see the full message and react to it. +

+
+
+ ); + } + + const channelId = item.item.channelId; + + return ( +
+
+
+
+ +
+
+

{item.senderLabel}

+ + {item.categoryLabel} + + {item.channelLabel ? ( + + #{item.channelLabel} + + ) : null} +
+ +
+ {item.fullTimestampLabel} + {canOpenChannel ? : null} + {canOpenChannel ? ( + Linked to an active channel + ) : ( + Inbox only + )} +
+
+
+ +
+ +
+
+ } + /> + } + /> + } + /> +
+
+ {canOpenChannel && channelId ? ( + onOpenChannel(channelId)} + icon={} + /> + ) : null} + + ) : ( + + ) + } + /> +
+ +
+
+
+
+ +
+

{item.subject}

+
+
+ +
+
+
+ +
+ {localReplies.map((reply) => ( +
+
+ +
+

+ {reply.authorLabel} +

+

+ {reply.fullTimestampLabel} +

+
+
+ +
+ ))} +
+
+ + +
+ ); +} + +function HeaderIconAction({ + icon, + label, + onClick, +}: { + icon: React.ReactNode; + label: string; + onClick?: () => void; +}) { + const button = ( + + ); + + return ( + + {button} + {label} + + ); +} + +function HeaderMoreMenu({ + canDelete, + isDeletingMessage, + onArchive, + onDelete, +}: { + canDelete: boolean; + isDeletingMessage: boolean; + onArchive: () => void; + onDelete: () => void; +}) { + const trigger = ( + + ); + + return ( + + + + {trigger} + + More actions + + + + + Archive for now + + + + + Delete message + + + + ); +} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx new file mode 100644 index 000000000..85fa1f6df --- /dev/null +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -0,0 +1,164 @@ +import { Search } from "lucide-react"; + +import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; +import { groupInboxItems } from "@/features/home/lib/inbox"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ + { value: "all", label: "All" }, + { value: "mention", label: "Mentions" }, + { value: "needs_action", label: "Needs Action" }, +]; + +type InboxListPaneProps = { + doneSet: ReadonlySet; + filter: InboxFilter; + items: InboxItem[]; + onFilterChange: (filter: InboxFilter) => void; + onSearchChange: (value: string) => void; + onSelect: (itemId: string) => void; + searchValue: string; + selectedId: string | null; +}; + +export function InboxListPane({ + doneSet, + filter, + items, + onFilterChange, + onSearchChange, + onSelect, + searchValue, + selectedId, +}: InboxListPaneProps) { + const groups = groupInboxItems(items); + + return ( +
+
+
+ + onSearchChange(event.target.value)} + placeholder="Search mail" + value={searchValue} + /> +
+ +
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
+
+ +
+ {groups.length === 0 ? ( +
+
+

No messages found

+

+ Try a different search or switch back to all mail. +

+
+
+ ) : ( + groups.map((group) => ( +
+
+

+ {group.label} +

+
+ +
+ {group.items.map((item) => { + const isSelected = item.id === selectedId; + const isDone = doneSet.has(item.id); + + return ( + + ); + })} +
+
+ )) + )} +
+
+ ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index e4dbe8dd6..2cee344aa 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,5 +1,5 @@ // biome-ignore format: keep compact to stay within file size limit -import { Activity, Bot, Home, PenSquare, Plus, Search, Zap } from "lucide-react"; +import { Activity, Bot, Inbox, PenSquare, Plus, Search, Zap } from "lucide-react"; import * as React from "react"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; @@ -335,11 +335,11 @@ export function AppSidebar({ - - Home + + Inbox {homeBadgeCount > 0 ? ( { ).toBeVisible(); await page.getByTestId("channel-delete-confirm").click(); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); await expect(page.getByTestId("stream-list")).not.toContainText(channelName); }); diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index d3fea920c..f472590a3 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -292,8 +292,8 @@ test("live mentions refetch the home feed without waiting for polling", async ({ }, ]); - await targetPage.getByRole("button", { name: "Home" }).click(); - await expect(targetPage.getByTestId("chat-title")).toHaveText("Home"); + await targetPage.getByRole("button", { name: "Inbox" }).click(); + await expect(targetPage.getByTestId("chat-title")).toHaveText("Inbox"); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect .poll(() => getLoggedNotificationCount(targetPage), { timeout: 3_000 }) @@ -352,15 +352,10 @@ test("live forum mentions refetch the home feed without waiting for polling", as }, ]); - await targetPage.getByRole("button", { name: "Home" }).click(); - await expect(targetPage.getByTestId("chat-title")).toHaveText("Home"); - await expect( - targetPage.getByRole("heading", { name: "Mentions" }), - ).toBeVisible(); - const mentionsSection = targetPage.locator("section").filter({ - has: targetPage.getByRole("heading", { name: "Mentions" }), - }); - await expect(mentionsSection).toContainText(message); + await targetPage.getByRole("button", { name: "Inbox" }).click(); + await expect(targetPage.getByTestId("chat-title")).toHaveText("Inbox"); + await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); + await expect(targetPage.getByTestId("home-inbox-list")).toContainText(message); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect .poll(() => getLoggedNotificationCount(targetPage), { timeout: 3_000 }) diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 921e712fe..77e2bf382 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -33,7 +33,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => { await expect(page.getByTestId("profile-about")).toHaveValue(about); await page.getByTestId("settings-close").click(); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); await expect(page.getByTestId("open-settings")).toBeVisible(); await openSettings(page, "profile"); @@ -195,8 +195,8 @@ test("notification settings drive the Home badge and desktop alerts", async ({ await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); await expect.poll(getAppBadgeCount).toBe(1); - await page.getByRole("button", { name: "Home" }).click(); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await page.getByRole("button", { name: "Inbox" }).click(); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); await expect.poll(getAppBadgeCount).toBe(0); }); @@ -212,7 +212,7 @@ test("desktop notification clicks open the matching forum thread", async ({ "On", ); await page.getByTestId("settings-close").click(); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); await page.evaluate(() => { const win = window as Window & { @@ -280,7 +280,7 @@ test("opens settings with the keyboard shortcut and updates theme", async ({ page, }) => { await page.goto("/"); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); await page.keyboard.press( process.platform === "darwin" ? "Meta+," : "Control+,", @@ -344,12 +344,12 @@ test("opens settings with the keyboard shortcut and updates theme", async ({ process.platform === "darwin" ? "Meta+," : "Control+,", ); await expect(page.getByTestId("settings-view")).toHaveCount(0); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); }); test("supports webview zoom keyboard shortcuts", async ({ page }) => { await page.goto("/"); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); await page.keyboard.press( process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal", diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 7c30608c3..de75a91ac 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -133,19 +133,15 @@ test("create agent supports parallelism and system prompt overrides", async ({ }); test("opens a mocked channel from the home feed", async ({ page }) => { - const mentionsSection = page.locator("section").filter({ - has: page.getByRole("heading", { name: "Mentions" }), - }); + const inboxList = page.getByTestId("home-inbox-list"); await page.goto("/"); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); - await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible(); - await expect( - page.getByText("Please review the release checklist."), - ).toBeVisible(); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); + await expect(inboxList).toContainText("Please review the release checklist."); - await mentionsSection.getByRole("button", { name: "Open general" }).click(); + await inboxList.getByText("Please review the release checklist.").first().click(); + await page.getByRole("button", { name: "Open channel" }).click(); await expect(page).toHaveURL( /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50\?messageId=mock-feed-mention$/, @@ -236,14 +232,10 @@ test("opens a mocked forum activity item from the home feed", async ({ }); test("home feed renders resolved author labels", async ({ page }) => { - const mentionsSection = page.locator("section").filter({ - has: page.getByRole("heading", { name: "Mentions" }), - }); - await page.goto("/"); - await expect(mentionsSection).toContainText("alice"); - await expect(mentionsSection).not.toContainText("You"); + await expect(page.getByTestId("home-inbox-list")).toContainText("alice"); + await expect(page.getByTestId("home-inbox-list")).not.toContainText("You"); }); test("opens relay-backed search from the sidebar and loads the exact result", async ({ diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts index 2b6d2e44a..914490ddd 100644 --- a/desktop/tests/e2e/stream.spec.ts +++ b/desktop/tests/e2e/stream.spec.ts @@ -43,7 +43,7 @@ async function ensureTimelineScrollable( const message = `${prefix} seed ${index}`; - await expect(input).toHaveAttribute("contenteditable", "true"); + await expect(input).toBeEnabled(); await input.fill(message); await sendButton.click(); await expectTimelineToContain(receiverPage, message); @@ -110,17 +110,34 @@ test("loads the home feed from the relay", async ({ page }) => { await installRelayBridge(page, "tyler"); await page.goto("/"); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); - await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Needs Action" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Channel Activity" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Agent Updates" }), - ).toBeVisible(); + await expect(page.getByTestId("chat-title")).toHaveText("Inbox"); + await expect(page.getByTestId("home-inbox")).toBeVisible(); + await expect(page.getByTestId("home-inbox-list")).toContainText( + "Please review the release checklist.", + ); + await expect(page.getByTestId("home-inbox-detail")).toBeVisible(); +}); + +test("shows sent inbox replies immediately in the inbox detail pane", async ({ + page, +}) => { + const reply = `Inbox reply ${Date.now()}`; + + await installRelayBridge(page, "tyler"); + await page.goto("/"); + + await page + .getByTestId("home-inbox-list") + .getByText("Please review the release checklist.") + .first() + .click(); + await expect(page.getByTestId("home-inbox-detail")).toBeVisible(); + await expect(page.getByTestId("message-input")).toBeEnabled(); + + await page.getByTestId("message-input").fill(reply); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("home-inbox-detail")).toContainText(reply); }); test("creates a relay-backed stream", async ({ page }) => { From d0c818d3200f7a5cd229bd3caa48563ea6b7c656 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 28 Apr 2026 22:00:54 -0400 Subject: [PATCH 02/15] feat(desktop): refine inbox signal and layout Made-with: Cursor --- desktop/src/features/chat/ui/ChatHeader.tsx | 22 ++- desktop/src/features/home/lib/inbox.ts | 164 +++++++++++++----- desktop/src/features/home/ui/HomeScreen.tsx | 57 ++---- desktop/src/features/home/ui/HomeView.tsx | 100 +++++++++-- .../src/features/home/ui/InboxDetailPane.tsx | 80 ++++----- .../src/features/home/ui/InboxListPane.tsx | 49 +++++- .../features/messages/ui/MessageComposer.tsx | 42 +++-- 7 files changed, 337 insertions(+), 177 deletions(-) diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 49dd369bb..51f9d5071 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -10,12 +10,13 @@ import { } from "lucide-react"; import type * as React from "react"; +import { cn } from "@/shared/lib/cn"; import type { ChannelType, ChannelVisibility } from "@/shared/api/types"; type ChatHeaderProps = { actions?: React.ReactNode; title: string; - description: string; + description?: string; channelType?: ChannelType; visibility?: ChannelVisibility; mode?: "home" | "channel" | "agents" | "workflows" | "pulse"; @@ -73,7 +74,10 @@ export function ChatHeader({ }: ChatHeaderProps) { return (
@@ -96,12 +100,14 @@ export function ChatHeader({
) : null}
-

- {description} -

+ {description ? ( +

+ {description} +

+ ) : null}
{actions ?
{actions}
: null} diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 97dfbe9de..c2471d84f 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -2,19 +2,31 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { + FeedItem, + FeedItemCategory, + HomeFeedResponse, +} from "@/shared/api/types"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; -export type InboxFilter = "all" | "mention" | "needs_action"; +export type InboxFilter = + | "all" + | "mention" + | "needs_action" + | "activity" + | "agent_activity"; export type InboxItem = { avatarUrl: string | null; id: string; item: FeedItem; + categories: FeedItemCategory[]; categoryLabel: string; channelLabel: string | null; fullTimestampLabel: string; isActionRequired: boolean; + latestActivityAt: number; mentionNames: string[]; preview: string; searchableText: string; @@ -126,6 +138,34 @@ function feedPreview(item: FeedItem) { return "No additional details were attached to this event."; } +function categoryLabelFor(category: FeedItemCategory) { + return category === "needs_action" + ? "Needs Action" + : category === "mention" + ? "Mention" + : category === "agent_activity" + ? "Agent update" + : "Activity"; +} + +function categoryPriority(category: FeedItemCategory) { + switch (category) { + case "needs_action": + return 0; + case "mention": + return 1; + case "agent_activity": + return 2; + case "activity": + return 3; + } +} + +function getInboxThreadKey(item: FeedItem) { + const thread = getThreadReference(item.tags); + return thread.rootId ?? thread.parentId ?? item.id; +} + function formatInboxTimestamp(unixSeconds: number) { const date = new Date(unixSeconds * 1_000); const now = new Date(); @@ -155,7 +195,7 @@ export function groupInboxItems(items: InboxItem[]): InboxGroup[] { const now = new Date(); for (const item of items) { - const date = new Date(item.item.createdAt * 1_000); + const date = new Date(item.latestActivityAt * 1_000); const dayDiff = diffInDays(now, date); const label = dayDiff === 0 @@ -190,46 +230,86 @@ export function buildInboxItems({ return []; } - const items = [...feed.feed.mentions, ...feed.feed.needsAction].sort( - (left, right) => right.createdAt - left.createdAt, - ); + const feedItems = [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ...feed.feed.agentActivity, + ]; + + const threadGroups = new Map< + string, + { + items: FeedItem[]; + latestActivityAt: number; + rootItem: FeedItem | null; + } + >(); + + for (const item of feedItems) { + const threadKey = getInboxThreadKey(item); + const group = threadGroups.get(threadKey) ?? { + items: [], + latestActivityAt: 0, + rootItem: null, + }; - return items.map((item) => { - const senderLabel = resolveUserLabel({ - pubkey: item.pubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }); - const subject = feedHeadline(item); - const preview = feedPreview(item); - const mentionNames = resolveMentionNames(item.tags, profiles) ?? []; - const channelLabel = item.channelName.trim() || null; - const categoryLabel = - item.category === "needs_action" ? "Needs Action" : "Mention"; - - return { - avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null, - id: item.id, - item, - categoryLabel, - channelLabel, - fullTimestampLabel: formatInboxFullTimestamp(item.createdAt), - isActionRequired: item.category === "needs_action", - mentionNames, - preview, - searchableText: [ + group.items.push(item); + group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt); + if (item.id === threadKey) { + group.rootItem = item; + } + + threadGroups.set(threadKey, group); + } + + return [...threadGroups.values()] + .sort((left, right) => right.latestActivityAt - left.latestActivityAt) + .map((group) => { + const latestItem = group.items.reduce((latest, current) => + current.createdAt > latest.createdAt ? current : latest, + ); + const item = group.rootItem ?? latestItem; + const categories = [ + ...new Set(group.items.map((groupItem) => groupItem.category)), + ].sort((left, right) => categoryPriority(left) - categoryPriority(right)); + const senderLabel = resolveUserLabel({ + pubkey: item.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const subject = feedHeadline(item); + const preview = feedPreview(item); + const mentionNames = resolveMentionNames(item.tags, profiles) ?? []; + const channelLabel = item.channelName.trim() || null; + const categoryLabel = categoryLabelFor(categories[0] ?? item.category); + + return { + avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null, + id: item.id, + item, + categories, + categoryLabel, + channelLabel, + fullTimestampLabel: formatInboxFullTimestamp(item.createdAt), + isActionRequired: categories.includes("needs_action"), + latestActivityAt: group.latestActivityAt, + mentionNames, + preview, + searchableText: [ + senderLabel, + subject, + preview, + ...group.items.map(feedPreview), + channelLabel ?? "", + categoryLabel, + ] + .join(" ") + .toLowerCase(), senderLabel, subject, - preview, - channelLabel ?? "", - categoryLabel, - ] - .join(" ") - .toLowerCase(), - senderLabel, - subject, - timestampLabel: formatInboxTimestamp(item.createdAt), - }; - }); + timestampLabel: formatInboxTimestamp(group.latestActivityAt), + }; + }); } diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index 6f081241f..21694e18f 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -1,9 +1,5 @@ -import { RefreshCcw } from "lucide-react"; - -import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; -import { Button } from "@/shared/ui/button"; type HomeScreenProps = { availableChannelIds: ReadonlySet; @@ -19,45 +15,22 @@ export function HomeScreen({ const homeFeedQuery = useHomeFeedQuery(); return ( - <> - { - void homeFeedQuery.refetch(); - }} - type="button" - variant="outline" - > - - Refresh - +
+ { + void homeFeedQuery.refetch(); + }} /> - -
- { - void homeFeedQuery.refetch(); - }} - /> -
- +
); } diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index aec499b93..f47186a53 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { RefreshCcw } from "lucide-react"; +import { useChannelsQuery } from "@/features/channels/hooks"; import { type InboxFilter, type InboxReply, @@ -10,6 +11,8 @@ import { import { useFeedItemState } from "@/features/home/useFeedItemState"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; +import { useChannelMessagesQuery } from "@/features/messages/hooks"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; @@ -17,6 +20,14 @@ import type { HomeFeedResponse } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; +function matchesInboxFilter(item: { categories: InboxFilter[] }, filter: InboxFilter) { + if (filter === "all") { + return item.categories.some((category) => category !== "activity"); + } + + return item.categories.includes(filter); +} + function HomeLoadingState() { return (
@@ -77,18 +88,58 @@ export function HomeView({ >({}); const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); const feedItems = React.useMemo( - () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), + () => + feed + ? [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ...feed.feed.agentActivity, + ] + : [], [feed], ); + + const channelsQuery = useChannelsQuery(); + const channels = channelsQuery.data; + const selectedChannelIdCandidate = React.useMemo(() => { + if (!selectedItemId) return null; + const match = feedItems.find((item) => item.id === selectedItemId); + return match?.channelId ?? null; + }, [feedItems, selectedItemId]); + const selectedChannel = React.useMemo(() => { + if (!selectedChannelIdCandidate || !channels) return null; + return ( + channels.find((channel) => channel.id === selectedChannelIdCandidate) ?? + null + ); + }, [channels, selectedChannelIdCandidate]); + + const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); + const channelMessages = channelMessagesQuery.data; + const threadEvents = React.useMemo(() => { + if (!selectedItemId || !channelMessages) return []; + return channelMessages + .filter((event) => { + if (event.id === selectedItemId) return false; + const ref = getThreadReference(event.tags); + return ( + ref.parentId === selectedItemId || ref.rootId === selectedItemId + ); + }) + .sort((a, b) => a.created_at - b.created_at); + }, [channelMessages, selectedItemId]); + const feedProfilePubkeys = React.useMemo( () => [ ...new Set([ ...feedItems.map((item) => item.pubkey), + ...threadEvents.map((event) => event.pubkey), ...(currentPubkey ? [currentPubkey] : []), ]), ], - [currentPubkey, feedItems], + [currentPubkey, feedItems, threadEvents], ); const feedProfilesQuery = useUsersBatchQuery( feedProfilePubkeys, @@ -110,8 +161,7 @@ export function HomeView({ const normalizedQuery = searchValue.trim().toLowerCase(); return inboxItems.filter((item) => { - const matchesFilter = - filter === "all" ? true : item.item.category === filter; + const matchesFilter = matchesInboxFilter(item, filter); const matchesQuery = normalizedQuery.length === 0 || item.searchableText.includes(normalizedQuery); @@ -121,9 +171,32 @@ export function HomeView({ }, [filter, inboxItems, searchValue]); const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? null; - const selectedItemReplies = selectedItem - ? localRepliesByItemId[selectedItem.id] ?? [] - : []; + const threadReplies = React.useMemo( + () => + threadEvents.map((event) => ({ + id: event.id, + authorLabel: resolveUserLabel({ + pubkey: event.pubkey, + currentPubkey, + profiles: feedProfiles, + preferResolvedSelfLabel: true, + }), + avatarUrl: + feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null, + content: event.content, + fullTimestampLabel: formatInboxFullTimestamp(event.created_at), + })), + [currentPubkey, feedProfiles, threadEvents], + ); + const selectedItemReplies = React.useMemo(() => { + if (!selectedItem) return []; + const localReplies = localRepliesByItemId[selectedItem.id] ?? []; + const remoteIds = new Set(threadReplies.map((reply) => reply.id)); + const pendingLocals = localReplies.filter( + (reply) => !remoteIds.has(reply.id), + ); + return [...threadReplies, ...pendingLocals]; + }, [localRepliesByItemId, selectedItem, threadReplies]); React.useEffect(() => { if (filteredItems.length === 0) { setSelectedItemId(null); @@ -136,6 +209,7 @@ export function HomeView({ }, [filteredItems, selectedItemId]); React.useEffect(() => { + void selectedItemId; setIsDeletingMessage(false); setIsSendingReply(false); }, [selectedItemId]); @@ -208,7 +282,10 @@ export function HomeView({ items={filteredItems} onFilterChange={setFilter} onSearchChange={setSearchValue} - onSelect={setSelectedItemId} + onSelect={(itemId) => { + setSelectedItemId(itemId); + markDone(itemId); + }} searchValue={searchValue} selectedId={selectedItemId} /> @@ -225,12 +302,7 @@ export function HomeView({ isDeletingMessage={isDeletingMessage} isSendingReply={isSendingReply} item={selectedItem} - localReplies={selectedItemReplies} - onArchive={() => { - if (selectedItem) { - handleToggleDone(selectedItem.id); - } - }} + replies={selectedItemReplies} onDelete={() => { if (!selectedItem || !canDelete) { return; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index a808c63b3..fe42dd6bd 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -1,14 +1,11 @@ import { ArrowUpRight, - Archive, CheckCheck, CircleDot, Mail, MailOpen, - Forward, MoreHorizontal, Reply, - ReplyAll, Trash2, } from "lucide-react"; import * as React from "react"; @@ -22,7 +19,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { @@ -42,8 +38,7 @@ type InboxDetailPaneProps = { isDeletingMessage?: boolean; isSendingReply?: boolean; item: InboxItem | null; - localReplies?: InboxReply[]; - onArchive: () => void; + replies?: InboxReply[]; onDelete: () => void; onOpenChannel: (channelId: string) => void; onSendReply: ( @@ -63,8 +58,7 @@ export function InboxDetailPane({ isDeletingMessage = false, isSendingReply = false, item, - localReplies = [], - onArchive, + replies = [], onDelete, onOpenChannel, onSendReply, @@ -152,22 +146,15 @@ export function InboxDetailPane({
-
- } - /> - } - /> - } - /> -
+ {canReply ? ( +
+ } + /> +
+ ) : null}
{canOpenChannel && channelId ? (
- + {canDelete ? ( + + ) : null}
- -
-

{item.subject}

-
@@ -214,10 +197,18 @@ export function InboxDetailPane({ tight />
- {localReplies.map((reply) => ( + {replies.length > 0 ? (
+ {replies.length === 1 ? "1 reply" : `${replies.length} replies`} +
+ ) : null} + {replies.map((reply) => ( +
@@ -249,12 +240,14 @@ export function InboxDetailPane({ @@ -293,14 +286,10 @@ function HeaderIconAction({ } function HeaderMoreMenu({ - canDelete, isDeletingMessage, - onArchive, onDelete, }: { - canDelete: boolean; isDeletingMessage: boolean; - onArchive: () => void; onDelete: () => void; }) { const trigger = ( @@ -324,14 +313,9 @@ function HeaderMoreMenu({ More actions - - - Archive for now - - diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 85fa1f6df..7bc5a7cad 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,4 @@ -import { Search } from "lucide-react"; +import { Inbox, Search } from "lucide-react"; import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; import { groupInboxItems } from "@/features/home/lib/inbox"; @@ -11,6 +11,8 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "all", label: "All" }, { value: "mention", label: "Mentions" }, { value: "needs_action", label: "Needs Action" }, + { value: "activity", label: "Activity" }, + { value: "agent_activity", label: "Agents" }, ]; type InboxListPaneProps = { @@ -38,7 +40,19 @@ export function InboxListPane({ return (
-
+
+
+ +

+ Inbox +

+
-

+

{item.senderLabel}

{item.isActionRequired ? ( @@ -134,18 +152,35 @@ export function InboxListPane({ ) : null}
- + {item.timestampLabel}
-

+

{item.preview}

{item.channelLabel ? ( - + #{item.channelLabel} ) : null} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 06bad1f23..0ae44dc23 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -17,6 +17,7 @@ import { } from "@/features/messages/lib/normalizeMentionClipboard"; import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerAttachments } from "./ComposerAttachments"; @@ -29,6 +30,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar"; type MessageComposerProps = { channelId?: string | null; channelName: string; + containerClassName?: string; disabled?: boolean; draftKey?: string; editTarget?: { @@ -58,6 +60,7 @@ type MessageComposerProps = { export function MessageComposer({ channelId = null, channelName, + containerClassName, disabled = false, draftKey, editTarget = null, @@ -87,6 +90,8 @@ export function MessageComposer({ const drafts = useDrafts(); const effectiveDraftKey = draftKey ?? channelId; const previousDraftKeyRef = React.useRef(null); + const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); + effectiveDraftKeyRef.current = effectiveDraftKey; const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); @@ -107,13 +112,11 @@ export function MessageComposer({ const onSendRef = React.useRef(onSend); const onEditSaveRef = React.useRef(onEditSave); const editTargetRef = React.useRef(editTarget); - const channelIdRef = React.useRef(channelId); disabledRef.current = disabled; isSendingRef.current = isSending; onSendRef.current = onSend; onEditSaveRef.current = onEditSave; editTargetRef.current = editTarget; - channelIdRef.current = channelId; // ── Refs consumed by Tiptap's submitOnEnter extension ────────────── const isAutocompleteOpenRef = React.useRef(false); @@ -352,11 +355,11 @@ export function MessageComposer({ channelLinks.clearChannels(); setIsEmojiPickerOpen(false); - const sendChannelId = channelIdRef.current; + const sentDraftKey = effectiveDraftKeyRef.current; try { await onSendRef.current(finalContent, pubkeys, mediaTags); - if (sendChannelId) { - drafts.clearDraft(sendChannelId); + if (sentDraftKey) { + drafts.clearDraft(sentDraftKey); } } catch { setContent(savedContent); @@ -498,7 +501,12 @@ export function MessageComposer({ // ── Render ────────────────────────────────────────────────────────── return ( -
+
- + {onCancelReply ? ( + + ) : null}
) : null} From 79fbade6de88598ea05a927990ce50796afdd19c Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 11 May 2026 12:36:43 -0400 Subject: [PATCH 03/15] feat(desktop): show inbox thread context Co-authored-by: Cursor --- desktop/src/features/home/lib/inbox.ts | 19 ++ desktop/src/features/home/ui/HomeView.tsx | 101 ++++++---- .../src/features/home/ui/InboxDetailPane.tsx | 136 +++++++++---- .../src/features/home/ui/InboxListPane.tsx | 170 ++++++++--------- .../features/home/useInboxThreadContext.ts | 179 ++++++++++++++++++ 5 files changed, 431 insertions(+), 174 deletions(-) create mode 100644 desktop/src/features/home/useInboxThreadContext.ts diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index c2471d84f..1c5f6a424 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -7,6 +7,7 @@ import type { FeedItem, FeedItemCategory, HomeFeedResponse, + RelayEvent, } from "@/shared/api/types"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; @@ -43,6 +44,12 @@ export type InboxReply = { id: string; }; +export type InboxContextMessage = InboxReply & { + depth: number; + isSelected: boolean; + mentionNames: string[]; +}; + export type InboxGroup = { label: string; items: InboxItem[]; @@ -190,6 +197,18 @@ export function formatInboxFullTimestamp(unixSeconds: number) { return fullTimeFormatter.format(new Date(unixSeconds * 1_000)); } +export function relayEventFromFeedItem(item: FeedItem): RelayEvent { + return { + content: item.content, + created_at: item.createdAt, + id: item.id, + kind: item.kind, + pubkey: item.pubkey, + sig: "", + tags: item.tags, + }; +} + export function groupInboxItems(items: InboxItem[]): InboxGroup[] { const groups = new Map(); const now = new Date(); diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 2c5ae0481..64e949d85 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -4,11 +4,13 @@ import { RefreshCcw } from "lucide-react"; import { useChannelsQuery } from "@/features/channels/hooks"; import { type InboxFilter, + type InboxContextMessage, type InboxReply, buildInboxItems, formatInboxFullTimestamp, } from "@/features/home/lib/inbox"; import { useFeedItemState } from "@/features/home/useFeedItemState"; +import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; import { useChannelMessagesQuery } from "@/features/messages/hooks"; @@ -16,7 +18,8 @@ import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; -import type { HomeFeedResponse } from "@/shared/api/types"; +import type { HomeFeedResponse, RelayEvent } from "@/shared/api/types"; +import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -62,6 +65,23 @@ function HomeLoadingState() { ); } +function getContextMessageDepth( + event: RelayEvent, + eventById: ReadonlyMap, +): number { + let depth = 0; + let parentId = getThreadReference(event.tags).parentId; + const seen = new Set([event.id]); + + while (parentId && eventById.has(parentId) && !seen.has(parentId)) { + depth += 1; + seen.add(parentId); + parentId = getThreadReference(eventById.get(parentId)?.tags ?? []).parentId; + } + + return depth; +} + type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; @@ -104,14 +124,14 @@ export function HomeView({ : [], [feed], ); + const selectedFeedItem = + feedItems.find((item) => item.id === selectedItemId) ?? null; const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data; const selectedChannelIdCandidate = React.useMemo(() => { - if (!selectedItemId) return null; - const match = feedItems.find((item) => item.id === selectedItemId); - return match?.channelId ?? null; - }, [feedItems, selectedItemId]); + return selectedFeedItem?.channelId ?? null; + }, [selectedFeedItem]); const selectedChannel = React.useMemo(() => { if (!selectedChannelIdCandidate || !channels) return null; return ( @@ -122,26 +142,20 @@ export function HomeView({ const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); const channelMessages = channelMessagesQuery.data; - const threadEvents = React.useMemo(() => { - if (!selectedItemId || !channelMessages) return []; - return channelMessages - .filter((event) => { - if (event.id === selectedItemId) return false; - const ref = getThreadReference(event.tags); - return ref.parentId === selectedItemId || ref.rootId === selectedItemId; - }) - .sort((a, b) => a.created_at - b.created_at); - }, [channelMessages, selectedItemId]); + const threadContext = useInboxThreadContext( + selectedFeedItem, + channelMessages, + ); const feedProfilePubkeys = React.useMemo( () => [ ...new Set([ ...feedItems.map((item) => item.pubkey), - ...threadEvents.map((event) => event.pubkey), + ...threadContext.events.map((event) => event.pubkey), ...(currentPubkey ? [currentPubkey] : []), ]), ], - [currentPubkey, feedItems, threadEvents], + [currentPubkey, feedItems, threadContext.events], ); const feedProfilesQuery = useUsersBatchQuery(feedProfilePubkeys, { enabled: feedProfilePubkeys.length > 0, @@ -170,32 +184,37 @@ export function HomeView({ }, [filter, inboxItems, searchValue]); const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? null; - const threadReplies = React.useMemo( - () => - threadEvents.map((event) => ({ - id: event.id, - authorLabel: resolveUserLabel({ - pubkey: event.pubkey, - currentPubkey, - profiles: feedProfiles, - preferResolvedSelfLabel: true, - }), - avatarUrl: - feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null, - content: event.content, - fullTimestampLabel: formatInboxFullTimestamp(event.created_at), - })), - [currentPubkey, feedProfiles, threadEvents], - ); + const contextMessages = React.useMemo(() => { + if (!selectedItem) { + return []; + } + + const eventById = new Map( + threadContext.events.map((event) => [event.id, event]), + ); + + return threadContext.events.map((event) => ({ + id: event.id, + authorLabel: resolveUserLabel({ + pubkey: event.pubkey, + currentPubkey, + profiles: feedProfiles, + preferResolvedSelfLabel: true, + }), + avatarUrl: feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null, + content: event.content, + depth: getContextMessageDepth(event, eventById), + fullTimestampLabel: formatInboxFullTimestamp(event.created_at), + isSelected: event.id === selectedItem.id, + mentionNames: resolveMentionNames(event.tags, feedProfiles) ?? [], + })); + }, [currentPubkey, feedProfiles, selectedItem, threadContext.events]); const selectedItemReplies = React.useMemo(() => { if (!selectedItem) return []; const localReplies = localRepliesByItemId[selectedItem.id] ?? []; - const remoteIds = new Set(threadReplies.map((reply) => reply.id)); - const pendingLocals = localReplies.filter( - (reply) => !remoteIds.has(reply.id), - ); - return [...threadReplies, ...pendingLocals]; - }, [localRepliesByItemId, selectedItem, threadReplies]); + const contextIds = new Set(contextMessages.map((message) => message.id)); + return localReplies.filter((reply) => !contextIds.has(reply.id)); + }, [contextMessages, localRepliesByItemId, selectedItem]); React.useEffect(() => { if (filteredItems.length === 0) { setSelectedItemId(null); @@ -300,7 +319,9 @@ export function HomeView({ isDone={selectedItem ? doneSet.has(selectedItem.id) : false} isDeletingMessage={isDeletingMessage} isSendingReply={isSendingReply} + isThreadContextLoading={threadContext.isLoading} item={selectedItem} + messages={contextMessages} replies={selectedItemReplies} onDelete={() => { if (!selectedItem || !canDelete) { diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 3f9e42e0f..931a8d2ba 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -10,7 +10,11 @@ import { } from "lucide-react"; import * as React from "react"; -import type { InboxItem, InboxReply } from "@/features/home/lib/inbox"; +import type { + InboxContextMessage, + InboxItem, + InboxReply, +} from "@/features/home/lib/inbox"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -37,7 +41,9 @@ type InboxDetailPaneProps = { isDone: boolean; isDeletingMessage?: boolean; isSendingReply?: boolean; + isThreadContextLoading?: boolean; item: InboxItem | null; + messages?: InboxContextMessage[]; replies?: InboxReply[]; onDelete: () => void; onOpenChannel: (channelId: string) => void; @@ -57,7 +63,9 @@ export function InboxDetailPane({ isDone, isDeletingMessage = false, isSendingReply = false, + isThreadContextLoading = false, item, + messages = [], replies = [], onDelete, onOpenChannel, @@ -96,6 +104,30 @@ export function InboxDetailPane({ } const channelId = item.item.channelId; + const selectedMessage = messages.find((message) => message.isSelected); + const pendingReplyMessages: InboxContextMessage[] = replies.map((reply) => ({ + ...reply, + depth: (selectedMessage?.depth ?? 0) + 1, + isSelected: false, + mentionNames: [], + })); + const displayMessages = + messages.length > 0 + ? [...messages, ...pendingReplyMessages] + : [ + { + authorLabel: item.senderLabel, + avatarUrl: item.avatarUrl, + content: item.preview, + depth: 0, + fullTimestampLabel: item.fullTimestampLabel, + id: item.id, + isSelected: true, + mentionNames: item.mentionNames, + }, + ...pendingReplyMessages, + ]; + const hasConversationContext = displayMessages.length > 1; return (
-
- +
+ {hasConversationContext + ? "Conversation context" + : item.categoryLabel} + {isThreadContextLoading ? ( + + Loading context... + + ) : null}
- {replies.length > 0 ? ( -
- {replies.length === 1 ? "1 reply" : `${replies.length} replies`} -
- ) : null} - {replies.map((reply) => ( -
-
- -
-

- {reply.authorLabel} -

-

- {reply.fullTimestampLabel} -

+ {displayMessages.map((message) => ( +
+
+ {message.depth > 0 ? ( + -
))}
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index b5b190144..03607e153 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,7 +1,6 @@ import { Inbox, Search } from "lucide-react"; import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; -import { groupInboxItems } from "@/features/home/lib/inbox"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; @@ -36,8 +35,6 @@ export function InboxListPane({ searchValue, selectedId, }: InboxListPaneProps) { - const groups = groupInboxItems(items); - return (
- {groups.length === 0 ? ( + {items.length === 0 ? (

@@ -100,106 +97,93 @@ export function InboxListPane({

) : ( - groups.map((group) => ( -
-
-

- {group.label} -

-
+
+ {items.map((item) => { + const isSelected = item.id === selectedId; + const isDone = doneSet.has(item.id); -
- {group.items.map((item) => { - const isSelected = item.id === selectedId; - const isDone = doneSet.has(item.id); - - return ( - - ); - })} -
-
- )) + #{item.channelLabel} + + ) : null} +
+
+ + ); + })} +
)}
diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts new file mode 100644 index 000000000..95766ce4b --- /dev/null +++ b/desktop/src/features/home/useInboxThreadContext.ts @@ -0,0 +1,179 @@ +import * as React from "react"; + +import { relayEventFromFeedItem } from "@/features/home/lib/inbox"; +import { + getChannelIdFromTags, + getThreadReference, +} from "@/features/messages/lib/threading"; +import { relayClient } from "@/shared/api/relayClient"; +import { getEventById } from "@/shared/api/tauri"; +import type { FeedItem, RelayEvent } from "@/shared/api/types"; +import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds"; + +type InboxThreadContextResult = { + events: RelayEvent[]; + isLoading: boolean; +}; + +const THREAD_CONTEXT_LIMIT = 100; + +function dedupeEvents(events: RelayEvent[]): RelayEvent[] { + const eventsById = new Map(); + for (const event of events) { + eventsById.set(event.id, event); + } + return [...eventsById.values()].sort((a, b) => a.created_at - b.created_at); +} + +function getThreadRootId(event: RelayEvent): string { + const thread = getThreadReference(event.tags); + return thread.rootId ?? thread.parentId ?? event.id; +} + +function isSameChannel(event: RelayEvent, channelId: string | null): boolean { + if (!channelId) { + return true; + } + return getChannelIdFromTags(event.tags) === channelId; +} + +export function useInboxThreadContext( + item: FeedItem | null, + channelMessages: RelayEvent[] | undefined, +): InboxThreadContextResult { + const [fetchedEvents, setFetchedEvents] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + + const selectedEvent = React.useMemo( + () => (item ? relayEventFromFeedItem(item) : null), + [item], + ); + + const selectedThreadRootId = selectedEvent + ? getThreadRootId(selectedEvent) + : null; + const selectedParentId = selectedEvent + ? getThreadReference(selectedEvent.tags).parentId + : null; + const selectedChannelId = item?.channelId ?? null; + + React.useEffect(() => { + let isCancelled = false; + + if (!selectedEvent || !selectedThreadRootId) { + setFetchedEvents([]); + setIsLoading(false); + return () => { + isCancelled = true; + }; + } + + async function loadContext() { + const targetEvent = selectedEvent; + const threadRootId = selectedThreadRootId; + if (!targetEvent || !threadRootId) { + return; + } + + setIsLoading(true); + + try { + const eventIds = new Set([threadRootId]); + if (selectedParentId) { + eventIds.add(selectedParentId); + } + + const ancestorEvents = await Promise.all( + [...eventIds] + .filter((eventId) => eventId !== targetEvent.id) + .map(async (eventId) => { + try { + return await getEventById(eventId); + } catch { + return null; + } + }), + ); + + const descendantEvents = + selectedChannelId && threadRootId + ? await relayClient + .fetchEvents({ + "#e": [threadRootId], + "#h": [selectedChannelId], + kinds: [...HOME_MENTION_EVENT_KINDS], + limit: THREAD_CONTEXT_LIMIT, + }) + .catch(() => []) + : []; + + if (isCancelled) { + return; + } + + setFetchedEvents( + dedupeEvents( + [...ancestorEvents, ...descendantEvents].filter( + (event): event is RelayEvent => + event !== null && isSameChannel(event, selectedChannelId), + ), + ), + ); + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + } + + void loadContext(); + + return () => { + isCancelled = true; + }; + }, [ + selectedChannelId, + selectedEvent, + selectedParentId, + selectedThreadRootId, + ]); + + const events = React.useMemo(() => { + if (!selectedEvent) { + return []; + } + + const localContext = (channelMessages ?? []).filter((event) => { + if (!isSameChannel(event, selectedChannelId)) { + return false; + } + + if (event.id === selectedEvent.id) { + return true; + } + + const thread = getThreadReference(event.tags); + return ( + event.id === selectedThreadRootId || + event.id === selectedParentId || + thread.rootId === selectedThreadRootId || + thread.parentId === selectedThreadRootId || + thread.parentId === selectedEvent.id + ); + }); + + return dedupeEvents([selectedEvent, ...fetchedEvents, ...localContext]); + }, [ + channelMessages, + fetchedEvents, + selectedChannelId, + selectedEvent, + selectedParentId, + selectedThreadRootId, + ]); + + return { + events, + isLoading, + }; +} From 27b2be3da09876c4aeb6625259a0450152b61b8c Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 11 May 2026 15:58:04 -0400 Subject: [PATCH 04/15] fix(desktop): simplify inbox header layout Co-authored-by: Cursor --- desktop/src/features/chat/ui/ChatHeader.tsx | 12 ++---------- desktop/src/features/home/ui/HomeScreen.tsx | 2 +- desktop/src/features/home/ui/InboxListPane.tsx | 18 +++--------------- 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 533d4bbcf..fd0900ab3 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -4,7 +4,7 @@ import { CircleDot, FileText, Hash, - Home, + Inbox, Lock, Zap, } from "lucide-react"; @@ -37,7 +37,7 @@ function ChannelIcon({ mode?: "home" | "channel" | "agents" | "workflows" | "pulse"; }) { if (mode === "home") { - return ; + return ; } if (mode === "agents") { @@ -111,14 +111,6 @@ export function ChatHeader({
) : null}
- {trimmedDescription ? ( -

- {trimmedDescription} -

- ) : null}
{actions ?
{actions}
: null} diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index 7ff7d9ef8..3093a0481 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -21,7 +21,7 @@ export function HomeScreen({ description="Personalized activity feed for mentions, reminders, channel activity, and agent work." mode="home" overlaysContent - title="Home" + title="Inbox" />
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 03607e153..1ec7f2c72 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,4 @@ -import { Inbox, Search } from "lucide-react"; +import { Search } from "lucide-react"; import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; import { cn } from "@/shared/lib/cn"; @@ -38,21 +38,9 @@ export function InboxListPane({ return (
-
- -

- Inbox -

-
) : ( -
+
{items.map((item) => { const isSelected = item.id === selectedId; const isDone = doneSet.has(item.id); From fe2c9307cbd3d0b33c0405616e081adbe26f66c5 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 11 May 2026 21:29:12 -0400 Subject: [PATCH 05/15] fix(desktop): align inbox composer overlay Co-authored-by: Cursor --- .../src/features/home/ui/InboxDetailPane.tsx | 166 +++++++++--------- 1 file changed, 86 insertions(+), 80 deletions(-) diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 931a8d2ba..bed32dfdd 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -222,93 +222,99 @@ export function InboxDetailPane({
-
-
-
- {hasConversationContext - ? "Conversation context" - : item.categoryLabel} - {isThreadContextLoading ? ( - - Loading context... - - ) : null} -
- {displayMessages.map((message) => ( -
-
- {message.depth > 0 ? ( -
); } From 36afb5f02e52b07b0cfaa393678368f8386d1b87 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 12 May 2026 11:32:14 -0400 Subject: [PATCH 06/15] fix(desktop): make inbox category filters clickable Co-authored-by: Cursor --- desktop/src/features/home/lib/inbox.ts | 20 +++++++++++++++---- .../src/features/home/ui/InboxListPane.tsx | 5 +---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 1c5f6a424..83ccc019c 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -250,10 +250,22 @@ export function buildInboxItems({ } const feedItems = [ - ...feed.feed.mentions, - ...feed.feed.needsAction, - ...feed.feed.activity, - ...feed.feed.agentActivity, + ...feed.feed.mentions.map((item) => ({ + ...item, + category: "mention" as const, + })), + ...feed.feed.needsAction.map((item) => ({ + ...item, + category: "needs_action" as const, + })), + ...feed.feed.activity.map((item) => ({ + ...item, + category: "activity" as const, + })), + ...feed.feed.agentActivity.map((item) => ({ + ...item, + category: "agent_activity" as const, + })), ]; const threadGroups = new Map< diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 1ec7f2c72..ab1a1a071 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -37,10 +37,7 @@ export function InboxListPane({ }: InboxListPaneProps) { return (
-
+
Date: Wed, 13 May 2026 13:06:17 -0400 Subject: [PATCH 07/15] fix(desktop): soften inbox context highlight Co-authored-by: Cursor --- .../src/features/home/ui/InboxDetailPane.tsx | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index bed32dfdd..65466f868 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -236,59 +236,63 @@ export function InboxDetailPane({ ) : null}
{displayMessages.map((message) => ( -
+
{message.depth > 0 ? ( ))} From 4232bb222522de581b917289afafa7811cc65a61 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 13 May 2026 14:05:06 -0400 Subject: [PATCH 08/15] fix(desktop): remove inbox list search field Co-authored-by: Cursor --- desktop/src/features/home/lib/inbox.ts | 11 --------- desktop/src/features/home/ui/HomeView.tsx | 16 ++----------- .../src/features/home/ui/InboxListPane.tsx | 24 +++---------------- 3 files changed, 5 insertions(+), 46 deletions(-) diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 83ccc019c..2bca6ffa8 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -30,7 +30,6 @@ export type InboxItem = { latestActivityAt: number; mentionNames: string[]; preview: string; - searchableText: string; senderLabel: string; subject: string; timestampLabel: string; @@ -328,16 +327,6 @@ export function buildInboxItems({ latestActivityAt: group.latestActivityAt, mentionNames, preview, - searchableText: [ - senderLabel, - subject, - preview, - ...group.items.map(feedPreview), - channelLabel ?? "", - categoryLabel, - ] - .join(" ") - .toLowerCase(), senderLabel, subject, timestampLabel: formatInboxTimestamp(group.latestActivityAt), diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index e282c4a53..a9509aa3d 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -102,7 +102,6 @@ export function HomeView({ onRefresh, }: HomeViewProps) { const [filter, setFilter] = React.useState("all"); - const [searchValue, setSearchValue] = React.useState(""); const [selectedItemId, setSelectedItemId] = React.useState( null, ); @@ -171,17 +170,8 @@ export function HomeView({ [currentPubkey, feed, feedProfiles], ); const filteredItems = React.useMemo(() => { - const normalizedQuery = searchValue.trim().toLowerCase(); - - return inboxItems.filter((item) => { - const matchesFilter = matchesInboxFilter(item, filter); - const matchesQuery = - normalizedQuery.length === 0 || - item.searchableText.includes(normalizedQuery); - - return matchesFilter && matchesQuery; - }); - }, [filter, inboxItems, searchValue]); + return inboxItems.filter((item) => matchesInboxFilter(item, filter)); + }, [filter, inboxItems]); const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? null; const contextMessages = React.useMemo(() => { @@ -299,12 +289,10 @@ export function HomeView({ filter={filter} items={filteredItems} onFilterChange={setFilter} - onSearchChange={setSearchValue} onSelect={(itemId) => { setSelectedItemId(itemId); markDone(itemId); }} - searchValue={searchValue} selectedId={selectedItemId} /> diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index ab1a1a071..ac94f0aa8 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,9 +1,6 @@ -import { Search } from "lucide-react"; - import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; import { UserAvatar } from "@/shared/ui/UserAvatar"; const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ @@ -19,9 +16,7 @@ type InboxListPaneProps = { filter: InboxFilter; items: InboxItem[]; onFilterChange: (filter: InboxFilter) => void; - onSearchChange: (value: string) => void; onSelect: (itemId: string) => void; - searchValue: string; selectedId: string | null; }; @@ -30,26 +25,13 @@ export function InboxListPane({ filter, items, onFilterChange, - onSearchChange, onSelect, - searchValue, selectedId, }: InboxListPaneProps) { return (
-
-
- - onSearchChange(event.target.value)} - placeholder="Search mail" - value={searchValue} - /> -
- -
+
+
{FILTER_OPTIONS.map((option) => (
From b52964dcb31886a1b9655a6cf860ebf65004c0f2 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 13 May 2026 14:30:52 -0400 Subject: [PATCH 09/15] fix(desktop): target replies from inbox threads Co-authored-by: Cursor --- desktop/src/features/home/lib/inbox.ts | 3 + desktop/src/features/home/ui/HomeView.tsx | 12 ++- .../src/features/home/ui/InboxDetailPane.tsx | 91 +++++++++++++++++-- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 2bca6ffa8..6130c5ed2 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -39,8 +39,11 @@ export type InboxReply = { authorLabel: string; avatarUrl: string | null; content: string; + depth?: number; fullTimestampLabel: string; id: string; + parentId?: string | null; + rootId?: string | null; }; export type InboxContextMessage = InboxReply & { diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index a9509aa3d..1aa58c4e5 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -326,7 +326,12 @@ export function HomeView({ }); }} onOpenChannel={onOpenChannel} - onSendReply={async (content, mentionPubkeys, mediaTags) => { + onSendReply={async ({ + content, + mediaTags, + mentionPubkeys, + parentEventId, + }) => { const channelId = selectedItem?.item.channelId; if (!selectedItem || !channelId || !canReply) { throw new Error("Replies are not available for this item."); @@ -338,7 +343,7 @@ export function HomeView({ const result = await sendChannelMessage( channelId, content, - itemToReply.id, + parentEventId, mediaTags, mentionPubkeys, ); @@ -357,8 +362,11 @@ export function HomeView({ ?.avatarUrl ?? null) : null, content, + depth: result.depth, fullTimestampLabel: formatInboxFullTimestamp(result.createdAt), id: result.eventId, + parentId: result.parentEventId, + rootId: result.rootEventId, }; setLocalRepliesByItemId((current) => ({ ...current, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 65466f868..9b3a3aac8 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -15,6 +15,8 @@ import type { InboxItem, InboxReply, } from "@/features/home/lib/inbox"; +import type { TimelineMessage } from "@/features/messages/types"; +import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -47,14 +49,32 @@ type InboxDetailPaneProps = { replies?: InboxReply[]; onDelete: () => void; onOpenChannel: (channelId: string) => void; - onSendReply: ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - ) => Promise; + onSendReply: (input: { + content: string; + mediaTags?: string[][]; + mentionPubkeys: string[]; + parentEventId: string; + }) => Promise; onToggleDone: () => void; }; +type InboxDisplayMessage = InboxContextMessage & { + depth: number; +}; + +function toActionBarMessage(message: InboxDisplayMessage): TimelineMessage { + return { + id: message.id, + author: message.authorLabel, + avatarUrl: message.avatarUrl, + body: message.content, + createdAt: 0, + depth: message.depth, + reactions: [], + time: message.fullTimestampLabel, + }; +} + export function InboxDetailPane({ canDelete, canOpenChannel, @@ -73,6 +93,8 @@ export function InboxDetailPane({ onToggleDone, }: InboxDetailPaneProps) { const detailPaneRef = React.useRef(null); + const [replyTargetId, setReplyTargetId] = React.useState(null); + const selectedItemId = item?.id ?? null; const focusComposer = React.useCallback(() => { window.requestAnimationFrame(() => { @@ -84,6 +106,11 @@ export function InboxDetailPane({ }); }, []); + React.useEffect(() => { + void selectedItemId; + setReplyTargetId(null); + }, [selectedItemId]); + if (!item) { return (
message.isSelected); - const pendingReplyMessages: InboxContextMessage[] = replies.map((reply) => ({ + const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({ ...reply, - depth: (selectedMessage?.depth ?? 0) + 1, + depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1, isSelected: false, mentionNames: [], })); - const displayMessages = + const displayMessages: InboxDisplayMessage[] = messages.length > 0 ? [...messages, ...pendingReplyMessages] : [ @@ -128,6 +155,24 @@ export function InboxDetailPane({ ...pendingReplyMessages, ]; const hasConversationContext = displayMessages.length > 1; + const replyTarget = + displayMessages.find((message) => message.id === replyTargetId) ?? null; + const composerParentEventId = replyTarget?.id ?? item.id; + const composerReplyTarget = + replyTarget && replyTarget.id !== item.id + ? { + author: replyTarget.authorLabel, + body: replyTarget.content, + id: replyTarget.id, + } + : null; + + const handleSelectReplyTarget = (message: InboxDisplayMessage) => { + setReplyTargetId((currentReplyTargetId) => + currentReplyTargetId === message.id ? null : message.id, + ); + focusComposer(); + }; return (
{ + setReplyTargetId(null); + focusComposer(); + }} icon={} />
@@ -282,6 +330,18 @@ export function InboxDetailPane({

{message.fullTimestampLabel}

+ {canReply ? ( +
+
+ handleSelectReplyTarget(message)} + reactions={[]} + /> +
+
+ ) : null}
setReplyTargetId(null) : undefined + } + onSend={(content, mentionPubkeys, mediaTags) => + onSendReply({ + content, + mediaTags, + mentionPubkeys, + parentEventId: composerParentEventId, + }) + } placeholder={ canReply ? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` : (disabledReplyReason ?? "Replies are not available for this item.") } + replyTarget={composerReplyTarget} />
From 50ccef6980bc22ee2ca223bc92864e5036266003 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 13 May 2026 17:32:38 -0400 Subject: [PATCH 10/15] fix(desktop): add inbox reactions and clean header Co-authored-by: Cursor --- desktop/src/features/home/lib/inbox.ts | 2 + desktop/src/features/home/ui/HomeView.tsx | 95 +++++++++++++++---- .../src/features/home/ui/InboxDetailPane.tsx | 39 +++++++- .../src/features/home/ui/InboxListPane.tsx | 2 +- 4 files changed, 114 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 6130c5ed2..0d6e7a99a 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -3,6 +3,7 @@ import { type UserProfileLookup, } from "@/features/profile/lib/identity"; import { getThreadReference } from "@/features/messages/lib/threading"; +import type { TimelineReaction } from "@/features/messages/types"; import type { FeedItem, FeedItemCategory, @@ -43,6 +44,7 @@ export type InboxReply = { fullTimestampLabel: string; id: string; parentId?: string | null; + reactions?: TimelineReaction[]; rootId?: string | null; }; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 1aa58c4e5..f6de6dfa2 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -13,12 +13,17 @@ import { useFeedItemState } from "@/features/home/useFeedItemState"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; -import { useChannelMessagesQuery } from "@/features/messages/hooks"; +import { + useChannelMessagesQuery, + useToggleReactionMutation, +} from "@/features/messages/hooks"; +import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { HomeFeedResponse, RelayEvent } from "@/shared/api/types"; +import { KIND_REACTION } from "@/shared/constants/kinds"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -82,6 +87,17 @@ function getContextMessageDepth( return depth; } +function getReactionTargetId(tags: string[][]) { + for (let index = tags.length - 1; index >= 0; index -= 1) { + const tag = tags[index]; + if (tag?.[0] === "e" && typeof tag[1] === "string") { + return tag[1]; + } + } + + return null; +} + type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; @@ -140,6 +156,7 @@ export function HomeView({ }, [channels, selectedChannelIdCandidate]); const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); + const toggleReactionMutation = useToggleReactionMutation(); const channelMessages = channelMessagesQuery.data; const threadContext = useInboxThreadContext( selectedFeedItem, @@ -151,10 +168,13 @@ export function HomeView({ ...new Set([ ...feedItems.map((item) => item.pubkey), ...threadContext.events.map((event) => event.pubkey), + ...(channelMessages ?? []) + .filter((event) => event.kind === KIND_REACTION) + .map((event) => event.pubkey), ...(currentPubkey ? [currentPubkey] : []), ]), ], - [currentPubkey, feedItems, threadContext.events], + [channelMessages, currentPubkey, feedItems, threadContext.events], ); const feedProfilesQuery = useUsersBatchQuery(feedProfilePubkeys, { enabled: feedProfilePubkeys.length > 0, @@ -182,23 +202,49 @@ export function HomeView({ const eventById = new Map( threadContext.events.map((event) => [event.id, event]), ); + const contextEventIds = new Set(eventById.keys()); + const reactionEvents = (channelMessages ?? []).filter((event) => { + if (event.kind !== KIND_REACTION) { + return false; + } - return threadContext.events.map((event) => ({ - id: event.id, - authorLabel: resolveUserLabel({ - pubkey: event.pubkey, - currentPubkey, - profiles: feedProfiles, - preferResolvedSelfLabel: true, - }), - avatarUrl: feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null, - content: event.content, - depth: getContextMessageDepth(event, eventById), - fullTimestampLabel: formatInboxFullTimestamp(event.created_at), - isSelected: event.id === selectedItem.id, - mentionNames: resolveMentionNames(event.tags, feedProfiles) ?? [], - })); - }, [currentPubkey, feedProfiles, selectedItem, threadContext.events]); + 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, + ); + + return timelineMessages.map((message) => { + const event = eventById.get(message.id); + return { + id: message.id, + authorLabel: message.author, + avatarUrl: message.avatarUrl ?? null, + content: message.body, + depth: event ? getContextMessageDepth(event, eventById) : message.depth, + fullTimestampLabel: formatInboxFullTimestamp(message.createdAt), + isSelected: message.id === selectedItem.id, + mentionNames: + resolveMentionNames(message.tags ?? [], feedProfiles) ?? [], + reactions: message.reactions, + }; + }); + }, [ + channelMessages, + currentPubkey, + feedProfiles, + selectedChannel, + selectedItem, + threadContext.events, + ]); const selectedItemReplies = React.useMemo(() => { if (!selectedItem) return []; const localReplies = localRepliesByItemId[selectedItem.id] ?? []; @@ -382,6 +428,19 @@ export function HomeView({ handleToggleDone(selectedItem.id); } }} + onToggleReaction={ + canReply + ? async (message, emoji, remove) => { + await toggleReactionMutation.mutateAsync({ + emoji, + eventId: message.id, + remove, + }); + await channelMessagesQuery.refetch(); + onRefresh(); + } + : undefined + } />
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9b3a3aac8..cb02be3b9 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -56,6 +56,11 @@ type InboxDetailPaneProps = { parentEventId: string; }) => Promise; onToggleDone: () => void; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; }; type InboxDisplayMessage = InboxContextMessage & { @@ -70,7 +75,7 @@ function toActionBarMessage(message: InboxDisplayMessage): TimelineMessage { body: message.content, createdAt: 0, depth: message.depth, - reactions: [], + reactions: message.reactions ?? [], time: message.fullTimestampLabel, }; } @@ -91,6 +96,7 @@ export function InboxDetailPane({ onOpenChannel, onSendReply, onToggleDone, + onToggleReaction, }: InboxDetailPaneProps) { const detailPaneRef = React.useRef(null); const [replyTargetId, setReplyTargetId] = React.useState(null); @@ -180,7 +186,7 @@ export function InboxDetailPane({ data-testid="home-inbox-detail" ref={detailPaneRef} > -
+
{message.fullTimestampLabel}

- {canReply ? ( + {canReply || onToggleReaction ? (
handleSelectReplyTarget(message)} - reactions={[]} + onReactionSelect={ + onToggleReaction + ? (emoji) => { + const actionBarMessage = + toActionBarMessage(message); + const remove = + actionBarMessage.reactions?.some( + (reaction) => + reaction.emoji === emoji && + reaction.reactedByCurrentUser, + ) ?? false; + return onToggleReaction( + actionBarMessage, + emoji, + remove, + ); + } + : undefined + } + onReply={ + canReply + ? () => handleSelectReplyTarget(message) + : undefined + } + reactions={message.reactions ?? []} />
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index ac94f0aa8..1e2409908 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -30,7 +30,7 @@ export function InboxListPane({ }: InboxListPaneProps) { return (
-
+
{FILTER_OPTIONS.map((option) => (