Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,8 @@ export function AppShell() {
);

const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
void openSearchHit(hit);
(hit: SearchHit, query: string) => {
void openSearchHit(hit, { query });
},
[openSearchHit],
);
Expand Down
59 changes: 50 additions & 9 deletions desktop/src/app/navigation/searchHitEventCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,35 @@ import type { RelayEvent, SearchHit } from "@/shared/api/types";

const MAX_CACHED_EVENTS = 200;
const searchHitEventCache = new Map<string, RelayEvent>();
const searchHitQueryCache = new Map<
string,
{ eventId: string; query: string }
>();

function trimCache() {
if (searchHitEventCache.size <= MAX_CACHED_EVENTS) {
return;
}

const overflow = searchHitEventCache.size - MAX_CACHED_EVENTS;
let removed = 0;
const eventOverflow = searchHitEventCache.size - MAX_CACHED_EVENTS;
let removedEvents = 0;
for (const key of searchHitEventCache.keys()) {
if (removed >= overflow) {
if (removedEvents >= eventOverflow) {
break;
}
searchHitEventCache.delete(key);
removed++;
for (const [navigationId, entry] of searchHitQueryCache) {
if (entry.eventId === key) {
searchHitQueryCache.delete(navigationId);
}
}
removedEvents++;
}

const queryOverflow = searchHitQueryCache.size - MAX_CACHED_EVENTS;
let removedQueries = 0;
for (const navigationId of searchHitQueryCache.keys()) {
if (removedQueries >= queryOverflow) {
break;
}
searchHitQueryCache.delete(navigationId);
removedQueries++;
}
}

Expand All @@ -31,15 +46,29 @@ export function buildSearchHitEvent(hit: SearchHit): RelayEvent {
};
}

export function cacheSearchHitEvent(hit: SearchHit): RelayEvent {
export function cacheSearchHitEvent(
hit: SearchHit,
query?: string,
searchNavigationId = hit.eventId,
): RelayEvent {
const event = buildSearchHitEvent(hit);
searchHitEventCache.set(event.id, event);
const trimmedQuery = query?.trim();
if (trimmedQuery) {
searchHitQueryCache.set(searchNavigationId, {
eventId: event.id,
query: trimmedQuery,
});
} else {
searchHitQueryCache.delete(searchNavigationId);
}
trimCache();
return event;
}

export function clearSearchHitEventCache(): void {
searchHitEventCache.clear();
searchHitQueryCache.clear();
}

export function getCachedSearchHitEvent(
Expand All @@ -51,3 +80,15 @@ export function getCachedSearchHitEvent(

return searchHitEventCache.get(eventId) ?? null;
}

export function consumeCachedSearchHitQuery(
searchNavigationId: string | null | undefined,
): { eventId: string; query: string } | null {
if (!searchNavigationId) {
return null;
}

const entry = searchHitQueryCache.get(searchNavigationId) ?? null;
searchHitQueryCache.delete(searchNavigationId);
return entry;
}
43 changes: 43 additions & 0 deletions desktop/src/app/navigation/searchHitNavigation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,56 @@ test("search-hit navigation preserves forced message routing while active", asyn
options: {
force: true,
messageId: "message",
searchNavigationId: undefined,
threadRootId: "thread-root",
},
},
]);
assert.equal(getCachedSearchHitEvent("message")?.id, "message");
});

test("search-hit navigation retains the query and marks repeated route activations", async () => {
clearSearchHitEventCache();
const { consumeCachedSearchHitQuery } = await import(
"./searchHitEventCache.ts"
);
const calls = [];

await openSearchHitWithNavigation(plainMessage, {
goChannel: async (channelId, options) => {
calls.push({ channelId, options });
return true;
},
goForumPost: async () => false,
query: " Mentions ",
});

const searchNavigationId = calls[0].options.searchNavigationId;
assert.match(searchNavigationId, /^message:/);
assert.deepEqual(consumeCachedSearchHitQuery(searchNavigationId), {
eventId: "message",
query: "Mentions",
});
assert.equal(consumeCachedSearchHitQuery(searchNavigationId), null);
});

test("forum-post search navigation marks same-route activations", async () => {
clearSearchHitEventCache();
const forumPost = { ...forumComment, eventId: "post", kind: 45001 };
const calls = [];

await openSearchHitWithNavigation(forumPost, {
goChannel: async () => false,
goForumPost: async (channelId, postId, options) => {
calls.push({ channelId, postId, options });
return true;
},
query: "mentions",
});

assert.match(calls[0].options.searchNavigationId, /^post:/);
});

test("cancelled search-hit navigation cannot repopulate cache or route", async () => {
clearSearchHitEventCache();
let resolveLookup;
Expand Down
17 changes: 14 additions & 3 deletions desktop/src/app/navigation/searchHitNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ import type { SearchHit } from "@/shared/api/types";

type SearchHitNavigationActions = {
force?: boolean;
query?: string;
goChannel: (
channelId: string,
options?: {
force?: boolean;
messageId?: string;
searchNavigationId?: string;
threadRootId?: string | null;
},
) => Promise<unknown>;
goForumPost: (
channelId: string,
postId: string,
options?: { force?: boolean; replyId?: string },
options?: {
force?: boolean;
replyId?: string;
searchNavigationId?: string;
},
) => Promise<unknown>;
signal?: AbortSignal;
};
Expand All @@ -30,8 +36,11 @@ export async function openSearchHitWithNavigation(
}

const isLifecycleBound = Boolean(actions.signal);
const searchNavigationId = actions.query
? `${hit.eventId}:${crypto.randomUUID()}`
: undefined;
if (!isLifecycleBound) {
cacheSearchHitEvent(hit);
cacheSearchHitEvent(hit, actions.query, searchNavigationId);
}

const destination = await resolveDestination(hit);
Expand All @@ -42,19 +51,21 @@ export async function openSearchHitWithNavigation(
if (isLifecycleBound) {
// Delay community-scoped writes for notification routing until async
// destination resolution completes and its owner is still current.
cacheSearchHitEvent(hit);
cacheSearchHitEvent(hit, actions.query, searchNavigationId);
}

if (destination.kind === "forum-post") {
return actions.goForumPost(destination.channelId, destination.postId, {
force: actions.force,
replyId: destination.replyId,
searchNavigationId,
});
}

return actions.goChannel(destination.channelId, {
force: actions.force,
messageId: destination.messageId,
searchNavigationId,
threadRootId: destination.threadRootId,
});
}
17 changes: 16 additions & 1 deletion desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ export function useAppNavigation() {
* silently swallowed (block/buzz#3509). */
force?: boolean;
messageId?: string;
/** Search result id that makes repeated same-route activations observable. */
searchNavigationId?: string;
replace?: boolean;
/** Open this thread panel directly without waiting for a timeline row. */
thread?: string;
Expand All @@ -284,6 +286,9 @@ export function useAppNavigation() {
threadRootId: options.threadRootId ?? undefined,
}
: {}),
...(options?.searchNavigationId
? { searchNavigationId: options.searchNavigationId }
: {}),
...(options?.agentSession
? { agentSession: options.agentSession }
: {}),
Expand Down Expand Up @@ -329,6 +334,8 @@ export function useAppNavigation() {
force?: boolean;
replace?: boolean;
replyId?: string;
/** Search result id that makes repeated same-route activations observable. */
searchNavigationId?: string;
},
) => {
return commitNavigation(
Expand All @@ -338,7 +345,12 @@ export function useAppNavigation() {
channelId,
postId,
},
search: options?.replyId ? { replyId: options.replyId } : {},
search: {
...(options?.replyId ? { replyId: options.replyId } : {}),
...(options?.searchNavigationId
? { searchNavigationId: options.searchNavigationId }
: {}),
},
},
{
force: options?.force,
Expand Down Expand Up @@ -406,6 +418,8 @@ export function useAppNavigation() {
* Used by desktop-notification activation so a click is never
* silently swallowed (block/buzz#3509). */
force?: boolean;
/** Search text to highlight after opening this result. */
query?: string;
/** Stop notification-driven routing when its owning lifecycle ends. */
signal?: AbortSignal;
},
Expand All @@ -414,6 +428,7 @@ export function useAppNavigation() {
force: behavior?.force,
goChannel,
goForumPost,
query: behavior?.query,
signal: behavior?.signal,
}),
[goChannel, goForumPost],
Expand Down
58 changes: 46 additions & 12 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from "react";

import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import {
getCachedSearchHitEvent,
consumeCachedSearchHitQuery,
} from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useOpenChannelDirectoryQuery } from "@/features/channels/openChannelDirectory";
Expand All @@ -20,6 +23,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
autoSendDraftKey: string | null;
channelId: string;
searchNavigationId: string | null;
selectedPostId: string | null;
targetMessageId: string | null;
targetReplyId: string | null;
Expand Down Expand Up @@ -100,6 +104,7 @@ async function fetchRouteTargetEvents(
export function ChannelRouteScreen({
autoSendDraftKey,
channelId,
searchNavigationId,
selectedPostId,
targetMessageId,
targetReplyId,
Expand Down Expand Up @@ -132,23 +137,50 @@ export function ChannelRouteScreen({
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
});
const [searchHighlight, setSearchHighlight] = React.useState<{
messageId: string;
query: string;
} | null>(null);
const activeSearchNavigationIdRef = React.useRef<string | null>(null);

// Reset spliced target events when the channel context changes (channel
// switch or entering/leaving a forum post). Tied to channel identity rather
// than the route target so clearing the `messageId` param mid-channel keeps
// the deep-linked row in view. Seeded with the mount key so the initial
// Reset spliced target events and search highlighting when the channel
// changes. Tied to channel identity rather
// than the route target so clearing the `messageId` param or resolving a
// forum post mid-channel keeps the clicked highlight in view. Seeded with
// the mount key so the initial
// cache-seeded events survive first commit; only a genuine channel change
// clears them. Declared before the fetch effect so a channel switch clears
// stale events before the new target is fetched.
const previousResetKeyRef = React.useRef<string>(
`${channelId}::${selectedPostId ?? ""}`,
);
const previousResetKeyRef = React.useRef<string>(channelId);
React.useEffect(() => {
const resetKey = `${channelId}::${selectedPostId ?? ""}`;
if (previousResetKeyRef.current === resetKey) return;
previousResetKeyRef.current = resetKey;
if (previousResetKeyRef.current === channelId) return;
previousResetKeyRef.current = channelId;
activeSearchNavigationIdRef.current = null;
setTargetMessageEvents([]);
}, [channelId, selectedPostId]);
setSearchHighlight(null);
}, [channelId]);

React.useEffect(() => {
if (!searchNavigationId) {
activeSearchNavigationIdRef.current = null;
setSearchHighlight(null);
return;
}
if (activeSearchNavigationIdRef.current === searchNavigationId) {
return;
}

activeSearchNavigationIdRef.current = searchNavigationId;
const highlight = consumeCachedSearchHitQuery(searchNavigationId);
setSearchHighlight(
highlight
? {
messageId: highlight.eventId,
query: highlight.query,
}
: null,
);
}, [searchNavigationId]);

React.useEffect(() => {
let isCancelled = false;
Expand Down Expand Up @@ -234,6 +266,8 @@ export function ChannelRouteScreen({
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
targetSearchMessageId={searchHighlight?.messageId}
targetSearchQuery={searchHighlight?.query}
/>
);
}
Loading
Loading