mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -231,6 +231,7 @@ export type SearchHit = {
|
||||
channelName: string | null;
|
||||
createdAt: number;
|
||||
score: number;
|
||||
threadRootId?: string | null;
|
||||
};
|
||||
|
||||
export type SearchMessagesResponse = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user