feat(desktop): show inbox thread context

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-11 12:36:43 -04:00
parent e7105713b8
commit 79fbade6de
5 changed files with 432 additions and 175 deletions

View File

@@ -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<string, InboxItem[]>();
const now = new Date();

View File

@@ -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<string, RelayEvent>,
): number {
let depth = 0;
let parentId = getThreadReference(event.tags).parentId;
const seen = new Set<string>([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<InboxReply[]>(
() =>
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<InboxContextMessage[]>(() => {
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<InboxReply[]>(() => {
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) {

View File

@@ -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 (
<section
@@ -192,49 +224,71 @@ export function InboxDetailPane({
<div className="min-h-0 flex-1 overflow-y-auto py-6">
<div>
<div className="px-6 pb-5">
<Markdown
className="max-w-none text-left text-[15px] text-foreground"
content={item.preview}
mentionNames={item.mentionNames}
tight
/>
<div className="px-6 pb-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{hasConversationContext
? "Conversation context"
: item.categoryLabel}
{isThreadContextLoading ? (
<span className="ml-2 font-normal normal-case tracking-normal">
Loading context...
</span>
) : null}
</div>
{replies.length > 0 ? (
<div
className="border-t border-border/60 px-6 pt-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"
data-testid="home-inbox-replies-header"
>
{replies.length === 1 ? "1 reply" : `${replies.length} replies`}
</div>
) : null}
{replies.map((reply) => (
<div
className="px-6 py-5"
data-testid="home-inbox-reply"
key={reply.id}
>
<div className="mb-3 flex items-center gap-3">
<UserAvatar
avatarUrl={reply.avatarUrl}
className="h-8 w-8 rounded-md"
displayName={reply.authorLabel}
size="md"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">
{reply.authorLabel}
</p>
<p className="text-xs text-muted-foreground">
{reply.fullTimestampLabel}
</p>
{displayMessages.map((message) => (
<div className="px-6 py-3" key={message.id}>
<div
className={cn(
"relative rounded-2xl px-3 py-2",
message.isSelected
? "bg-primary/10 ring-1 ring-primary/25"
: "",
)}
data-testid={
message.isSelected
? "home-inbox-selected-message"
: "home-inbox-context-message"
}
style={{
marginLeft: `${Math.min(message.depth, 6) * 24}px`,
}}
>
{message.depth > 0 ? (
<div
aria-hidden="true"
className="absolute bottom-2 top-2 border-l border-border/70"
style={{ left: "-12px" }}
/>
) : null}
<div className="mb-3 flex items-center gap-3">
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-8 w-8 rounded-md"
displayName={message.authorLabel}
size="md"
/>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">
{message.authorLabel}
</p>
{message.isSelected ? (
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-primary">
Inbox item
</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{message.fullTimestampLabel}
</p>
</div>
</div>
<Markdown
className="max-w-none text-left text-[15px] text-foreground"
content={message.content}
mentionNames={message.mentionNames}
tight
/>
</div>
<Markdown
className="max-w-none text-left text-[15px] text-foreground"
content={reply.content}
tight
/>
</div>
))}
</div>

View File

@@ -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 (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background">
<div
@@ -88,7 +85,7 @@ export function InboxListPane({
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-testid="home-inbox-list"
>
{groups.length === 0 ? (
{items.length === 0 ? (
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
<div>
<p className="text-sm font-medium text-foreground">
@@ -100,106 +97,93 @@ export function InboxListPane({
</div>
</div>
) : (
groups.map((group) => (
<div
className="border-b border-border/60 last:border-b-0"
key={group.label}
>
<div className="sticky top-0 z-10 bg-background px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/95">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{group.label}
</p>
</div>
<div className="divide-y divide-border/60">
{items.map((item) => {
const isSelected = item.id === selectedId;
const isDone = doneSet.has(item.id);
<div>
{group.items.map((item) => {
const isSelected = item.id === selectedId;
const isDone = doneSet.has(item.id);
return (
<button
className={cn(
"flex w-full items-start gap-2.5 border-l px-4 py-2 text-left transition-colors",
isSelected
? "border-l-primary bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
)}
data-testid={`home-inbox-item-${item.id}`}
key={item.id}
onClick={() => onSelect(item.id)}
type="button"
>
<div className="relative">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-8 w-8 rounded-md"
displayName={item.senderLabel}
size="md"
/>
{!isDone ? (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
) : null}
</div>
return (
<button
className={cn(
"flex w-full items-start gap-2.5 border-l px-4 py-2 text-left transition-colors",
isSelected
? "border-l-primary bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
)}
data-testid={`home-inbox-item-${item.id}`}
key={item.id}
onClick={() => onSelect(item.id)}
type="button"
>
<div className="relative">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-8 w-8 rounded-md"
displayName={item.senderLabel}
size="md"
/>
{!isDone ? (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p
className={cn(
"truncate text-sm text-foreground",
isDone ? "font-normal" : "font-bold",
)}
>
{item.senderLabel}
</p>
{item.isActionRequired ? (
<span className="inline-flex shrink-0 items-center text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
Needs action
</span>
) : null}
</div>
</div>
<span
<div className="flex items-center gap-2">
<p
className={cn(
"shrink-0 text-xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
"truncate text-sm text-foreground",
isDone ? "font-normal" : "font-bold",
)}
>
{item.timestampLabel}
</span>
</div>
<p
className={cn(
"mt-0.5 line-clamp-2 text-sm leading-5",
isDone
? "font-normal text-muted-foreground"
: "font-semibold text-foreground",
)}
>
{item.preview}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{item.channelLabel ? (
<span
className={cn(
"text-[11px] text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
#{item.channelLabel}
{item.senderLabel}
</p>
{item.isActionRequired ? (
<span className="inline-flex shrink-0 items-center text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
Needs action
</span>
) : null}
</div>
</div>
</button>
);
})}
</div>
</div>
))
<span
className={cn(
"shrink-0 text-xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{item.timestampLabel}
</span>
</div>
<p
className={cn(
"mt-0.5 line-clamp-2 text-sm leading-5",
isDone
? "font-normal text-muted-foreground"
: "font-semibold text-foreground",
)}
>
{item.preview}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{item.channelLabel ? (
<span
className={cn(
"text-[11px] text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
#{item.channelLabel}
</span>
) : null}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</section>

View File

@@ -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<string, RelayEvent>();
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<RelayEvent[]>([]);
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<string>([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,
};
}