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,
|
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],
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user