diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index c2471d84fa..1c5f6a4242 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 2c5ae04810..64e949d854 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 3f9e42e0ff..931a8d2ba2 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 b5b190144f..03607e1539 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.timestampLabel} + +
+ +

+ {item.preview} +

+ +
+ {item.channelLabel ? ( + + #{item.channelLabel} + + ) : null} +
+
+ + ); + })} +
)}
diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts new file mode 100644 index 0000000000..95766ce4b6 --- /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, + }; +}