diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index c791acdfe..413a41754 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -21,6 +21,7 @@ import { useOpenDmMutation, } from "@/features/channels/hooks"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { useThreadFollows } from "@/features/messages/lib/useThreadFollows"; import { useHomeFeedNotifications, @@ -107,6 +108,7 @@ function toSearchHit(target: DesktopNotificationTarget): SearchHit | null { channelName: target.channelName ?? null, createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000), score: 0, + threadRootId: target.threadRootId ?? null, }; } @@ -228,6 +230,8 @@ export function AppShell() { : content : "New message"; + const threadRootId = getThreadReference(event.tags).rootId ?? null; + void sendDesktopNotification({ title: channelName, body, @@ -239,6 +243,7 @@ export function AppShell() { eventId: event.id, kind: event.kind, pubkey: event.pubkey, + threadRootId, }, }).then((didSend) => { if (!didSend) return; diff --git a/desktop/src/app/navigation/resolveSearchHitDestination.ts b/desktop/src/app/navigation/resolveSearchHitDestination.ts index 2e8d3df8b..2575bcfd4 100644 --- a/desktop/src/app/navigation/resolveSearchHitDestination.ts +++ b/desktop/src/app/navigation/resolveSearchHitDestination.ts @@ -8,6 +8,7 @@ export type SearchHitDestination = kind: "channel"; channelId: string; messageId?: string; + threadRootId?: string | null; } | { kind: "forum-post"; @@ -68,5 +69,6 @@ export async function resolveSearchHitDestination( kind: "channel", channelId: hit.channelId, messageId: hit.eventId, + threadRootId: hit.threadRootId ?? null, }; } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f08e9f2ae..5efd18c17 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -135,6 +135,7 @@ export function useAppNavigation() { options?: { messageId?: string; replace?: boolean; + threadRootId?: string | null; }, ) => commitNavigation( @@ -143,7 +144,12 @@ export function useAppNavigation() { params: { channelId, }, - search: options?.messageId ? { messageId: options.messageId } : {}, + search: options?.messageId + ? { + messageId: options.messageId, + threadRootId: options.threadRootId ?? undefined, + } + : {}, }, { replace: options?.replace, @@ -217,6 +223,7 @@ export function useAppNavigation() { return goChannel(destination.channelId, { messageId: destination.messageId, + threadRootId: destination.threadRootId, }); }, [goChannel, goForumPost], diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 08a6df924..431ec753e 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -15,6 +15,7 @@ type ChannelRouteScreenProps = { selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; + targetThreadRootId: string | null; }; export function ChannelRouteScreen({ @@ -22,6 +23,7 @@ export function ChannelRouteScreen({ selectedPostId, targetMessageId, targetReplyId, + targetThreadRootId, }: ChannelRouteScreenProps) { const { closeForumPost, goForumPost } = useAppNavigation(); const channelsQuery = useChannelsQuery(); @@ -30,42 +32,61 @@ export function ChannelRouteScreen({ const channels = channelsQuery.data ?? []; const activeChannel = channels.find((channel) => channel.id === channelId) ?? null; - const [targetMessageEvent, setTargetMessageEvent] = - React.useState(() => - getCachedSearchHitEvent(targetMessageId), - ); + const [targetMessageEvents, setTargetMessageEvents] = React.useState< + RelayEvent[] + >(() => { + const cachedTarget = getCachedSearchHitEvent(targetMessageId); + return cachedTarget ? [cachedTarget] : []; + }); React.useEffect(() => { let isCancelled = false; if (!targetMessageId || selectedPostId) { - setTargetMessageEvent(null); + setTargetMessageEvents([]); return () => { isCancelled = true; }; } - setTargetMessageEvent(getCachedSearchHitEvent(targetMessageId)); - void getEventById(targetMessageId) - .then((event) => { - if (!isCancelled) { - setTargetMessageEvent(event); + const cachedTarget = getCachedSearchHitEvent(targetMessageId); + setTargetMessageEvents(cachedTarget ? [cachedTarget] : []); + + const eventIds = [ + targetMessageId, + targetThreadRootId && targetThreadRootId !== targetMessageId + ? targetThreadRootId + : null, + ].filter((eventId): eventId is string => eventId !== null); + + void Promise.all( + eventIds.map(async (eventId) => { + try { + return await getEventById(eventId); + } catch (error) { + console.error("Failed to load route event", eventId, error); + return null; } - }) - .catch((error) => { - if (!isCancelled) { - console.error( - "Failed to load route target event", - targetMessageId, - error, + }), + ).then((events) => { + if (!isCancelled) { + setTargetMessageEvents((currentEvents) => { + const fetchedEvents = events.filter( + (event): event is RelayEvent => event !== null, ); - } - }); + const eventsById = new Map(); + for (const event of [...currentEvents, ...fetchedEvents]) { + eventsById.set(event.id, event); + } + return Array.from(eventsById.values()); + }); + } + }); return () => { isCancelled = true; }; - }, [selectedPostId, targetMessageId]); + }, [selectedPostId, targetMessageId, targetThreadRootId]); if (channelsQuery.isPending && !activeChannel) { return ( @@ -89,7 +110,7 @@ export function ChannelRouteScreen({ }} selectedForumPostId={selectedPostId} targetForumReplyId={targetReplyId} - targetMessageEvent={targetMessageEvent} + targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} /> ); diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 94f153d5e..85d34ed8e 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -41,6 +41,7 @@ function ForumPostRouteComponent() { selectedPostId={postId} targetMessageId={null} targetReplyId={search.replyId ?? null} + targetThreadRootId={null} /> ); diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 2394d5fb0..eb16c6bfe 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -5,6 +5,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { messageId?: string; + threadRootId?: string; }; function validateChannelSearch( @@ -15,6 +16,10 @@ function validateChannelSearch( typeof search.messageId === "string" && search.messageId.length > 0 ? search.messageId : undefined, + threadRootId: + typeof search.threadRootId === "string" && search.threadRootId.length > 0 + ? search.threadRootId + : undefined, }; } @@ -41,6 +46,7 @@ function ChannelRouteComponent() { selectedPostId={null} targetMessageId={search.messageId ?? null} targetReplyId={null} + targetThreadRootId={search.threadRootId ?? null} /> ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 4437a54d5..fd3af4d90 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -66,7 +66,7 @@ type ChannelScreenProps = { onSelectForumPost: (postId: string) => void; selectedForumPostId: string | null; targetForumReplyId: string | null; - targetMessageEvent: RelayEvent | null; + targetMessageEvents: RelayEvent[]; targetMessageId: string | null; }; @@ -78,7 +78,7 @@ export function ChannelScreen({ onSelectForumPost, selectedForumPostId, targetForumReplyId, - targetMessageEvent, + targetMessageEvents, targetMessageId, }: ChannelScreenProps) { const { @@ -144,9 +144,11 @@ export function ChannelScreen({ const resolvedMessages = React.useMemo(() => { const currentMessages = messagesQuery.data ?? []; - if (!activeChannel || !targetMessageEvent) return currentMessages; - return mergeMessages(currentMessages, targetMessageEvent); - }, [activeChannel, messagesQuery.data, targetMessageEvent]); + if (!activeChannel || targetMessageEvents.length === 0) { + return currentMessages; + } + return targetMessageEvents.reduce(mergeMessages, currentMessages); + }, [activeChannel, messagesQuery.data, targetMessageEvents]); const messageAuthorPubkeys = React.useMemo( () => collectMessageAuthorPubkeys(resolvedMessages), [resolvedMessages], diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index e08c189ff..f42292132 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -18,6 +18,7 @@ export type DesktopNotificationTarget = { eventId: string | null; kind: number | null; pubkey?: string; + threadRootId?: string | null; }; type DesktopNotificationPayload = { @@ -73,6 +74,8 @@ function parseNotificationTarget( const kind = typeof candidate.kind === "number" ? candidate.kind : null; const pubkey = typeof candidate.pubkey === "string" ? candidate.pubkey : undefined; + const threadRootId = + typeof candidate.threadRootId === "string" ? candidate.threadRootId : null; if (!channelId && !eventId) { return null; @@ -86,6 +89,7 @@ function parseNotificationTarget( eventId, kind, pubkey, + threadRootId, }; } diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index da02f625a..273d2f130 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -5,6 +5,7 @@ import { truncatePubkey, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { getThreadReference } from "@/features/messages/lib/threading"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { collectHomeAlertItems, @@ -99,6 +100,7 @@ export function useFeedDesktopNotifications( const deliverFeedNotification = React.useEffectEvent( async (item: FeedItem, senderName?: string) => { + const threadRootId = getThreadReference(item.tags).rootId ?? null; const didSend = await sendDesktopNotification({ body: notificationBody(item), target: { @@ -109,6 +111,7 @@ export function useFeedDesktopNotifications( eventId: item.id, kind: item.kind, pubkey: item.pubkey, + threadRootId, }, title: notificationTitle(item, senderName), }); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 39111531a..6172524a8 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -231,6 +231,7 @@ export type SearchHit = { channelName: string | null; createdAt: number; score: number; + threadRootId?: string | null; }; export type SearchMessagesResponse = { diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index a67f68002..6408f6937 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -571,7 +571,10 @@ function MarkdownInner({ // "the thread root is a forum post" up front would require an // event lookup we don't currently have synchronously; the brief // explicitly allows skipping that detection and falling through. - void goChannel(link.channelId, { messageId: link.messageId }); + void goChannel(link.channelId, { + messageId: link.messageId, + threadRootId: link.threadRootId, + }); }, imetaByUrl, mentionPubkeysByName, diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index ff7a74443..08c02e029 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -24,7 +24,10 @@ export function useMessageDeepLinks() { let cancelled = false; const unlistenPromise = listenForMessageDeepLinks((payload) => { if (cancelled) return; - void goChannel(payload.channelId, { messageId: payload.messageId }); + void goChannel(payload.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); }); return () => { cancelled = true;