fix(desktop): route notification clicks to thread context (#790)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-06-01 11:31:30 -04:00
committed by GitHub
parent 33e37de6e3
commit f2c266bac2
12 changed files with 87 additions and 29 deletions
+5
View File
@@ -21,6 +21,7 @@ import {
useOpenDmMutation, useOpenDmMutation,
} from "@/features/channels/hooks"; } from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows"; import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
import { import {
useHomeFeedNotifications, useHomeFeedNotifications,
@@ -107,6 +108,7 @@ function toSearchHit(target: DesktopNotificationTarget): SearchHit | null {
channelName: target.channelName ?? null, channelName: target.channelName ?? null,
createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000), createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000),
score: 0, score: 0,
threadRootId: target.threadRootId ?? null,
}; };
} }
@@ -228,6 +230,8 @@ export function AppShell() {
: content : content
: "New message"; : "New message";
const threadRootId = getThreadReference(event.tags).rootId ?? null;
void sendDesktopNotification({ void sendDesktopNotification({
title: channelName, title: channelName,
body, body,
@@ -239,6 +243,7 @@ export function AppShell() {
eventId: event.id, eventId: event.id,
kind: event.kind, kind: event.kind,
pubkey: event.pubkey, pubkey: event.pubkey,
threadRootId,
}, },
}).then((didSend) => { }).then((didSend) => {
if (!didSend) return; if (!didSend) return;
@@ -8,6 +8,7 @@ export type SearchHitDestination =
kind: "channel"; kind: "channel";
channelId: string; channelId: string;
messageId?: string; messageId?: string;
threadRootId?: string | null;
} }
| { | {
kind: "forum-post"; kind: "forum-post";
@@ -68,5 +69,6 @@ export async function resolveSearchHitDestination(
kind: "channel", kind: "channel",
channelId: hit.channelId, channelId: hit.channelId,
messageId: hit.eventId, messageId: hit.eventId,
threadRootId: hit.threadRootId ?? null,
}; };
} }
@@ -135,6 +135,7 @@ export function useAppNavigation() {
options?: { options?: {
messageId?: string; messageId?: string;
replace?: boolean; replace?: boolean;
threadRootId?: string | null;
}, },
) => ) =>
commitNavigation( commitNavigation(
@@ -143,7 +144,12 @@ export function useAppNavigation() {
params: { params: {
channelId, channelId,
}, },
search: options?.messageId ? { messageId: options.messageId } : {}, search: options?.messageId
? {
messageId: options.messageId,
threadRootId: options.threadRootId ?? undefined,
}
: {},
}, },
{ {
replace: options?.replace, replace: options?.replace,
@@ -217,6 +223,7 @@ export function useAppNavigation() {
return goChannel(destination.channelId, { return goChannel(destination.channelId, {
messageId: destination.messageId, messageId: destination.messageId,
threadRootId: destination.threadRootId,
}); });
}, },
[goChannel, goForumPost], [goChannel, goForumPost],
+42 -21
View File
@@ -15,6 +15,7 @@ type ChannelRouteScreenProps = {
selectedPostId: string | null; selectedPostId: string | null;
targetMessageId: string | null; targetMessageId: string | null;
targetReplyId: string | null; targetReplyId: string | null;
targetThreadRootId: string | null;
}; };
export function ChannelRouteScreen({ export function ChannelRouteScreen({
@@ -22,6 +23,7 @@ export function ChannelRouteScreen({
selectedPostId, selectedPostId,
targetMessageId, targetMessageId,
targetReplyId, targetReplyId,
targetThreadRootId,
}: ChannelRouteScreenProps) { }: ChannelRouteScreenProps) {
const { closeForumPost, goForumPost } = useAppNavigation(); const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery(); const channelsQuery = useChannelsQuery();
@@ -30,42 +32,61 @@ export function ChannelRouteScreen({
const channels = channelsQuery.data ?? []; const channels = channelsQuery.data ?? [];
const activeChannel = const activeChannel =
channels.find((channel) => channel.id === channelId) ?? null; channels.find((channel) => channel.id === channelId) ?? null;
const [targetMessageEvent, setTargetMessageEvent] = const [targetMessageEvents, setTargetMessageEvents] = React.useState<
React.useState<RelayEvent | null>(() => RelayEvent[]
getCachedSearchHitEvent(targetMessageId), >(() => {
); const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
});
React.useEffect(() => { React.useEffect(() => {
let isCancelled = false; let isCancelled = false;
if (!targetMessageId || selectedPostId) { if (!targetMessageId || selectedPostId) {
setTargetMessageEvent(null); setTargetMessageEvents([]);
return () => { return () => {
isCancelled = true; isCancelled = true;
}; };
} }
setTargetMessageEvent(getCachedSearchHitEvent(targetMessageId)); const cachedTarget = getCachedSearchHitEvent(targetMessageId);
void getEventById(targetMessageId) setTargetMessageEvents(cachedTarget ? [cachedTarget] : []);
.then((event) => {
if (!isCancelled) { const eventIds = [
setTargetMessageEvent(event); 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) => { ).then((events) => {
if (!isCancelled) { if (!isCancelled) {
console.error( setTargetMessageEvents((currentEvents) => {
"Failed to load route target event", const fetchedEvents = events.filter(
targetMessageId, (event): event is RelayEvent => event !== null,
error,
); );
} const eventsById = new Map<string, RelayEvent>();
}); for (const event of [...currentEvents, ...fetchedEvents]) {
eventsById.set(event.id, event);
}
return Array.from(eventsById.values());
});
}
});
return () => { return () => {
isCancelled = true; isCancelled = true;
}; };
}, [selectedPostId, targetMessageId]); }, [selectedPostId, targetMessageId, targetThreadRootId]);
if (channelsQuery.isPending && !activeChannel) { if (channelsQuery.isPending && !activeChannel) {
return ( return (
@@ -89,7 +110,7 @@ export function ChannelRouteScreen({
}} }}
selectedForumPostId={selectedPostId} selectedForumPostId={selectedPostId}
targetForumReplyId={targetReplyId} targetForumReplyId={targetReplyId}
targetMessageEvent={targetMessageEvent} targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId} targetMessageId={targetMessageId}
/> />
); );
@@ -41,6 +41,7 @@ function ForumPostRouteComponent() {
selectedPostId={postId} selectedPostId={postId}
targetMessageId={null} targetMessageId={null}
targetReplyId={search.replyId ?? null} targetReplyId={search.replyId ?? null}
targetThreadRootId={null}
/> />
</React.Suspense> </React.Suspense>
); );
@@ -5,6 +5,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteSearch = { type ChannelRouteSearch = {
messageId?: string; messageId?: string;
threadRootId?: string;
}; };
function validateChannelSearch( function validateChannelSearch(
@@ -15,6 +16,10 @@ function validateChannelSearch(
typeof search.messageId === "string" && search.messageId.length > 0 typeof search.messageId === "string" && search.messageId.length > 0
? search.messageId ? search.messageId
: undefined, : undefined,
threadRootId:
typeof search.threadRootId === "string" && search.threadRootId.length > 0
? search.threadRootId
: undefined,
}; };
} }
@@ -41,6 +46,7 @@ function ChannelRouteComponent() {
selectedPostId={null} selectedPostId={null}
targetMessageId={search.messageId ?? null} targetMessageId={search.messageId ?? null}
targetReplyId={null} targetReplyId={null}
targetThreadRootId={search.threadRootId ?? null}
/> />
</React.Suspense> </React.Suspense>
); );
@@ -66,7 +66,7 @@ type ChannelScreenProps = {
onSelectForumPost: (postId: string) => void; onSelectForumPost: (postId: string) => void;
selectedForumPostId: string | null; selectedForumPostId: string | null;
targetForumReplyId: string | null; targetForumReplyId: string | null;
targetMessageEvent: RelayEvent | null; targetMessageEvents: RelayEvent[];
targetMessageId: string | null; targetMessageId: string | null;
}; };
@@ -78,7 +78,7 @@ export function ChannelScreen({
onSelectForumPost, onSelectForumPost,
selectedForumPostId, selectedForumPostId,
targetForumReplyId, targetForumReplyId,
targetMessageEvent, targetMessageEvents,
targetMessageId, targetMessageId,
}: ChannelScreenProps) { }: ChannelScreenProps) {
const { const {
@@ -144,9 +144,11 @@ export function ChannelScreen({
const resolvedMessages = React.useMemo(() => { const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? []; const currentMessages = messagesQuery.data ?? [];
if (!activeChannel || !targetMessageEvent) return currentMessages; if (!activeChannel || targetMessageEvents.length === 0) {
return mergeMessages(currentMessages, targetMessageEvent); return currentMessages;
}, [activeChannel, messagesQuery.data, targetMessageEvent]); }
return targetMessageEvents.reduce(mergeMessages, currentMessages);
}, [activeChannel, messagesQuery.data, targetMessageEvents]);
const messageAuthorPubkeys = React.useMemo( const messageAuthorPubkeys = React.useMemo(
() => collectMessageAuthorPubkeys(resolvedMessages), () => collectMessageAuthorPubkeys(resolvedMessages),
[resolvedMessages], [resolvedMessages],
@@ -18,6 +18,7 @@ export type DesktopNotificationTarget = {
eventId: string | null; eventId: string | null;
kind: number | null; kind: number | null;
pubkey?: string; pubkey?: string;
threadRootId?: string | null;
}; };
type DesktopNotificationPayload = { type DesktopNotificationPayload = {
@@ -73,6 +74,8 @@ function parseNotificationTarget(
const kind = typeof candidate.kind === "number" ? candidate.kind : null; const kind = typeof candidate.kind === "number" ? candidate.kind : null;
const pubkey = const pubkey =
typeof candidate.pubkey === "string" ? candidate.pubkey : undefined; typeof candidate.pubkey === "string" ? candidate.pubkey : undefined;
const threadRootId =
typeof candidate.threadRootId === "string" ? candidate.threadRootId : null;
if (!channelId && !eventId) { if (!channelId && !eventId) {
return null; return null;
@@ -86,6 +89,7 @@ function parseNotificationTarget(
eventId, eventId,
kind, kind,
pubkey, pubkey,
threadRootId,
}; };
} }
@@ -5,6 +5,7 @@ import {
truncatePubkey, truncatePubkey,
type UserProfileLookup, type UserProfileLookup,
} from "@/features/profile/lib/identity"; } from "@/features/profile/lib/identity";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { import {
collectHomeAlertItems, collectHomeAlertItems,
@@ -99,6 +100,7 @@ export function useFeedDesktopNotifications(
const deliverFeedNotification = React.useEffectEvent( const deliverFeedNotification = React.useEffectEvent(
async (item: FeedItem, senderName?: string) => { async (item: FeedItem, senderName?: string) => {
const threadRootId = getThreadReference(item.tags).rootId ?? null;
const didSend = await sendDesktopNotification({ const didSend = await sendDesktopNotification({
body: notificationBody(item), body: notificationBody(item),
target: { target: {
@@ -109,6 +111,7 @@ export function useFeedDesktopNotifications(
eventId: item.id, eventId: item.id,
kind: item.kind, kind: item.kind,
pubkey: item.pubkey, pubkey: item.pubkey,
threadRootId,
}, },
title: notificationTitle(item, senderName), title: notificationTitle(item, senderName),
}); });
+1
View File
@@ -231,6 +231,7 @@ export type SearchHit = {
channelName: string | null; channelName: string | null;
createdAt: number; createdAt: number;
score: number; score: number;
threadRootId?: string | null;
}; };
export type SearchMessagesResponse = { export type SearchMessagesResponse = {
+4 -1
View File
@@ -603,7 +603,10 @@ function MarkdownInner({
// "the thread root is a forum post" up front would require an // "the thread root is a forum post" up front would require an
// event lookup we don't currently have synchronously; the brief // event lookup we don't currently have synchronously; the brief
// explicitly allows skipping that detection and falling through. // 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, imetaByUrl,
mentionPubkeysByName, mentionPubkeysByName,
+4 -1
View File
@@ -24,7 +24,10 @@ export function useMessageDeepLinks() {
let cancelled = false; let cancelled = false;
const unlistenPromise = listenForMessageDeepLinks((payload) => { const unlistenPromise = listenForMessageDeepLinks((payload) => {
if (cancelled) return; if (cancelled) return;
void goChannel(payload.channelId, { messageId: payload.messageId }); void goChannel(payload.channelId, {
messageId: payload.messageId,
threadRootId: payload.threadRootId,
});
}); });
return () => { return () => {
cancelled = true; cancelled = true;