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,
} 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;
@@ -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,
};
}
@@ -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],
+42 -21
View File
@@ -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<RelayEvent | null>(() =>
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<string, RelayEvent>();
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}
/>
);
@@ -41,6 +41,7 @@ function ForumPostRouteComponent() {
selectedPostId={postId}
targetMessageId={null}
targetReplyId={search.replyId ?? null}
targetThreadRootId={null}
/>
</React.Suspense>
);
@@ -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}
/>
</React.Suspense>
);
@@ -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],
@@ -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,
};
}
@@ -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),
});
+1
View File
@@ -231,6 +231,7 @@ export type SearchHit = {
channelName: string | null;
createdAt: number;
score: number;
threadRootId?: string | null;
};
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
// 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,
+4 -1
View File
@@ -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;