From 29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 12:22:12 -0400 Subject: [PATCH] highlight search terms in results and messages (#6702) **Category:** fix **User Impact:** Search terms are now highlighted in yellow in Cmd+F results and in the message opened from a result. **Problem:** Search returned relevant messages, but users still had to reread each preview and destination message to discover where the query appeared. Search navigation could also lose or misapply highlighting during rapid typing, repeated navigation, thread opening, and forum navigation. **Solution:** Render match-focused previews with a shared yellow treatment, carry the result query through navigation, and apply it only to the clicked destination. The implementation binds highlights to the debounced result set, supports token and prefix matching, and handles channels, threads, forums, diffs, and wave messages consistently.
File changes **desktop/src/app/AppShell.tsx** Carries the active result query into search-hit navigation so the destination can preserve the user's context. **desktop/src/app/navigation/searchHitEventCache.ts** Adds a bounded, one-shot cache for result queries and navigation IDs alongside cached search-hit events. **desktop/src/app/navigation/searchHitNavigation.test.mjs** Covers query retention, one-shot consumption, repeated same-route activations, forum posts, forced routing, and cancellation. **desktop/src/app/navigation/searchHitNavigation.ts** Associates each search-result activation with a unique navigation ID and forwards it to channel or forum destinations. **desktop/src/app/navigation/useAppNavigation.ts** Extends channel and forum navigation to carry search navigation state without changing normal navigation behavior. **desktop/src/app/routes/ChannelRouteScreen.tsx** Consumes search highlight state after navigation and retains it for the selected message while clearing it on community context changes. **desktop/src/app/routes/channels.$channelId.posts.$postId.tsx** Validates and forwards forum search-navigation IDs. **desktop/src/app/routes/channels.$channelId.tsx** Validates and forwards channel search-navigation IDs. **desktop/src/features/channels/ui/ChannelPane.tsx** Passes the selected result ID and query into the main timeline and open thread panel. **desktop/src/features/channels/ui/ChannelPane.types.ts** Defines the destination-highlight contract for channel panes. **desktop/src/features/channels/ui/ChannelScreen.tsx** Routes destination highlighting to either forum content or the channel timeline. **desktop/src/features/channels/ui/ChannelScreen.types.ts** Defines route-level search-highlight inputs. **desktop/src/features/channels/ui/ForumChannelContent.tsx** Forwards search context into expanded forum threads. **desktop/src/features/forum/ui/ForumThreadPanel.tsx** Highlights the matching text in the selected forum post or reply. **desktop/src/features/forum/ui/ForumView.tsx** Carries selected-result context from forum routing into the thread panel. **desktop/src/features/messages/ui/DiffMessage.tsx** Forwards the query into diff descriptions and rendered diff content. **desktop/src/features/messages/ui/DiffViewer.tsx** Provides a highlighted match excerpt for structured diffs and highlights fallback raw diff text. **desktop/src/features/messages/ui/MessageRow.tsx** Passes destination queries into normal Markdown, diff, and wave message renderers. **desktop/src/features/messages/ui/MessageThreadPanel.tsx** Applies highlighting to the selected thread head or reply without tinting unrelated messages. **desktop/src/features/messages/ui/WaveMessageAttachment.tsx** Highlights matches in wave-message fallback text. **desktop/src/features/search/lib/searchMatch.test.mjs** Covers case-insensitive, literal, multi-term, prefix, one-character, and late-preview matches. **desktop/src/features/search/lib/searchMatch.ts** Centralizes token extraction, case-insensitive match splitting, and match-focused preview generation. **desktop/src/features/search/ui/HighlightedSearchText.tsx** Adds the reusable accessible mark renderer used by result and specialized message surfaces. **desktop/src/features/search/ui/TopbarSearch.tsx** Highlights result previews, centers excerpts around matches, strips search operators, and hides stale results during debounce transitions. **desktop/src/features/sidebar/ui/AppSidebar.types.ts** Updates the sidebar search callback contract to include the result query. **desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx** Passes query-aware result selection through the pinned search entry point. **desktop/src/shared/lib/rehypeSearchHighlight.ts** Uses the shared matcher to mark every matching Markdown text segment while leaving code blocks untouched. **desktop/src/shared/lib/searchHighlightStyle.ts** Defines one yellow highlight treatment for light and dark themes. **desktop/src/shared/ui/markdown/nodeCache.test.mjs** Verifies all Markdown matches, one-character scoped searches, code exclusion, and transient cache behavior. **desktop/src/shared/ui/markdown/nodeCache.ts** Enables transient highlighting for one-character scoped searches without polluting the Markdown cache. **desktop/tests/e2e/smoke.spec.ts** Exercises result and destination highlighting, same-route forum activation, and stale-query suppression end to end.
## Reproduction steps 1. Open a channel containing a longer message and press Cmd+F. 2. Search for a word that appears later in the message, such as `mentions`. 3. Confirm each matching result shows the word in yellow and keeps the match visible in its preview. 4. Open a result and confirm the same term is highlighted in yellow in the destination message. 5. Repeat with a thread reply or forum post, and quickly change the query to confirm stale results are not selectable. ## Demo - **Before:** Search results returned the correct message but offered no visual indication of where the query matched; opening the message also showed no match treatment. - **After:** Every matching term is marked in yellow in the result preview and in the exact channel, thread, or forum message opened from that result. --------- Signed-off-by: tulsi --- desktop/src/app/AppShell.tsx | 4 +- .../searchHighlightNavigation.test.mjs | 39 ++ .../navigation/searchHighlightNavigation.ts | 47 +++ .../navigation/searchHitNavigation.test.mjs | 40 ++ .../src/app/navigation/searchHitNavigation.ts | 19 +- .../src/app/navigation/useAppNavigation.ts | 39 +- desktop/src/app/routes/ChannelRouteScreen.tsx | 77 +++- .../channels.$channelId.posts.$postId.tsx | 7 +- .../src/app/routes/channels.$channelId.tsx | 8 +- .../routes/searchHighlightRouteState.test.mjs | 39 ++ .../app/routes/searchHighlightRouteState.ts | 17 + .../src/features/channels/ui/ChannelPane.tsx | 22 +- .../features/channels/ui/ChannelPane.types.ts | 4 + .../features/channels/ui/ChannelScreen.tsx | 375 +++++++++--------- .../channels/ui/ChannelScreen.types.ts | 4 + .../channels/ui/ForumChannelContent.tsx | 6 + .../channels/ui/searchTargetForwarding.tsx | 20 + .../channels/ui/useSearchHighlightProps.ts | 18 + .../features/forum/ui/ForumThreadPanel.tsx | 17 + desktop/src/features/forum/ui/ForumView.tsx | 6 + .../src/features/messages/ui/DiffMessage.tsx | 6 +- .../src/features/messages/ui/DiffViewer.tsx | 20 +- .../src/features/messages/ui/MessageRow.tsx | 2 + .../messages/ui/MessageThreadPanel.tsx | 12 + .../messages/ui/WaveMessageAttachment.tsx | 10 +- .../features/search/lib/searchMatch.test.mjs | 113 ++++++ .../src/features/search/lib/searchMatch.ts | 201 ++++++++++ .../search/ui/HighlightedSearchText.tsx | 26 ++ .../src/features/search/ui/TopbarSearch.tsx | 48 +-- .../features/sidebar/ui/AppSidebar.types.ts | 2 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 2 +- .../src/shared/lib/rehypeSearchHighlight.ts | 32 +- .../src/shared/lib/searchHighlightStyle.ts | 3 + .../src/shared/ui/markdown/nodeCache.test.mjs | 28 +- desktop/src/shared/ui/markdown/nodeCache.ts | 4 +- desktop/src/testing/e2eBridge.ts | 9 + desktop/tests/e2e/smoke.spec.ts | 165 ++++++++ 37 files changed, 1207 insertions(+), 284 deletions(-) create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.test.mjs create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.ts create mode 100644 desktop/src/app/routes/searchHighlightRouteState.test.mjs create mode 100644 desktop/src/app/routes/searchHighlightRouteState.ts create mode 100644 desktop/src/features/channels/ui/searchTargetForwarding.tsx create mode 100644 desktop/src/features/channels/ui/useSearchHighlightProps.ts create mode 100644 desktop/src/features/search/lib/searchMatch.test.mjs create mode 100644 desktop/src/features/search/lib/searchMatch.ts create mode 100644 desktop/src/features/search/ui/HighlightedSearchText.tsx create mode 100644 desktop/src/shared/lib/searchHighlightStyle.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0..468435e15e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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], ); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.test.mjs b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs new file mode 100644 index 0000000000..1e05a62669 --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { createSearchHighlightNavigation, parseSearchHighlightNavigation } = + await import("./searchHighlightNavigation.ts"); + +test("creates trimmed transient state with a unique activation id", () => { + const first = createSearchHighlightNavigation("message", " Mentions "); + const second = createSearchHighlightNavigation("message", "Mentions"); + + assert.deepEqual( + { messageId: first.messageId, query: first.query }, + { messageId: "message", query: "Mentions" }, + ); + assert.notEqual(first.activationId, second.activationId); +}); + +test("does not create highlight state for an empty query", () => { + assert.equal(createSearchHighlightNavigation("message", " "), undefined); + assert.equal( + createSearchHighlightNavigation("message", undefined), + undefined, + ); +}); + +test("parses only complete highlight navigation state", () => { + const state = { + activationId: "activation", + messageId: "message", + query: "mentions", + }; + + assert.deepEqual(parseSearchHighlightNavigation(state), state); + assert.equal( + parseSearchHighlightNavigation({ messageId: "message", query: "mentions" }), + null, + ); + assert.equal(parseSearchHighlightNavigation(null), null); +}); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.ts b/desktop/src/app/navigation/searchHighlightNavigation.ts new file mode 100644 index 0000000000..43d3506ec8 --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.ts @@ -0,0 +1,47 @@ +export type SearchHighlightNavigation = { + activationId: string; + messageId: string; + query: string; +}; + +export function createSearchHighlightNavigation( + messageId: string, + query: string | undefined, +): SearchHighlightNavigation | undefined { + const trimmedQuery = query?.trim(); + if (!trimmedQuery) { + return undefined; + } + + return { + activationId: crypto.randomUUID(), + messageId, + query: trimmedQuery, + }; +} + +export function parseSearchHighlightNavigation( + value: unknown, +): SearchHighlightNavigation | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as Partial; + if ( + typeof candidate.activationId !== "string" || + candidate.activationId.length === 0 || + typeof candidate.messageId !== "string" || + candidate.messageId.length === 0 || + typeof candidate.query !== "string" || + candidate.query.length === 0 + ) { + return null; + } + + return { + activationId: candidate.activationId, + messageId: candidate.messageId, + query: candidate.query, + }; +} diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs index 74e5f108af..02276d9eb3 100644 --- a/desktop/src/app/navigation/searchHitNavigation.test.mjs +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -51,6 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn options: { force: true, messageId: "message", + searchHighlight: undefined, threadRootId: "thread-root", }, }, @@ -58,6 +59,45 @@ test("search-hit navigation preserves forced message routing while active", asyn assert.equal(getCachedSearchHitEvent("message")?.id, "message"); }); +test("search-hit navigation carries trimmed highlight state and forces repeated activations", async () => { + clearSearchHitEventCache(); + const calls = []; + + await openSearchHitWithNavigation(plainMessage, { + goChannel: async (channelId, options) => { + calls.push({ channelId, options }); + return true; + }, + goForumPost: async () => false, + query: " Mentions ", + }); + + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "message"); + assert.equal(calls[0].options.searchHighlight.query, "Mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + +test("forum-post search navigation carries transient same-route activation state", 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.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "post"); + assert.equal(calls[0].options.searchHighlight.query, "mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + test("cancelled search-hit navigation cannot repopulate cache or route", async () => { clearSearchHitEventCache(); let resolveLookup; diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts index 8523340d48..6b180c33f0 100644 --- a/desktop/src/app/navigation/searchHitNavigation.ts +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -1,21 +1,28 @@ import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { createSearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import type { SearchHit } from "@/shared/api/types"; type SearchHitNavigationActions = { force?: boolean; + query?: string; goChannel: ( channelId: string, options?: { force?: boolean; messageId?: string; + searchHighlight?: ReturnType; threadRootId?: string | null; }, ) => Promise; goForumPost: ( channelId: string, postId: string, - options?: { force?: boolean; replyId?: string }, + options?: { + force?: boolean; + replyId?: string; + searchHighlight?: ReturnType; + }, ) => Promise; signal?: AbortSignal; }; @@ -30,6 +37,10 @@ export async function openSearchHitWithNavigation( } const isLifecycleBound = Boolean(actions.signal); + const searchHighlight = createSearchHighlightNavigation( + hit.eventId, + actions.query, + ); if (!isLifecycleBound) { cacheSearchHitEvent(hit); } @@ -47,14 +58,16 @@ export async function openSearchHitWithNavigation( if (destination.kind === "forum-post") { return actions.goForumPost(destination.channelId, destination.postId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), replyId: destination.replyId, + searchHighlight, }); } return actions.goChannel(destination.channelId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), messageId: destination.messageId, + searchHighlight, threadRootId: destination.threadRootId, }); } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 7a21f0dfbe..c82c4d96ee 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,6 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import { allowNavigation, @@ -32,14 +33,23 @@ export function useAppNavigation() { to: string; params?: Record; search?: Record; - state?: Record; + state?: + | Record + | (( + previousState: Record, + ) => Record); }, behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, ) => { const nextLocation = router.buildLocation(next as never); + const hasStateUpdate = next.state !== undefined; - if (location.href === nextLocation.href && !behavior.force) { + if ( + location.href === nextLocation.href && + !behavior.force && + !hasStateUpdate + ) { return false; } @@ -265,6 +275,9 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -290,6 +303,12 @@ export function useAppNavigation() { ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -329,6 +348,9 @@ export function useAppNavigation() { force?: boolean; replace?: boolean; replyId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; }, ) => { return commitNavigation( @@ -338,7 +360,15 @@ export function useAppNavigation() { channelId, postId, }, - search: options?.replyId ? { replyId: options.replyId } : {}, + search: { + ...(options?.replyId ? { replyId: options.replyId } : {}), + }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -406,6 +436,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; }, @@ -414,6 +446,7 @@ export function useAppNavigation() { force: behavior?.force, goChannel, goForumPost, + query: behavior?.query, signal: behavior?.signal, }), [goChannel, goForumPost], diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6f..03b08196af 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; @@ -20,6 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; + searchHighlight: SearchHighlightNavigation | null | undefined; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -100,6 +102,7 @@ async function fetchRouteTargetEvents( export function ChannelRouteScreen({ autoSendDraftKey, channelId, + searchHighlight, selectedPostId, targetMessageId, targetReplyId, @@ -132,23 +135,67 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); return cachedTarget ? [cachedTarget] : []; }); - - // 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 - // 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( - `${channelId}::${selectedPostId ?? ""}`, + const [activeSearchHighlight, setActiveSearchHighlight] = + React.useState(searchHighlight ?? null); + const appliedSearchActivationIdRef = React.useRef( + searchHighlight?.activationId ?? null, ); + + // Router state is transient and can be cleared by the target URL cleanup. + // Retain the applied activation locally until an ordinary route transition + // explicitly arrives without search state. React.useEffect(() => { - const resetKey = `${channelId}::${selectedPostId ?? ""}`; - if (previousResetKeyRef.current === resetKey) return; - previousResetKeyRef.current = resetKey; + if (searchHighlight === null) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + return; + } + if (!searchHighlight) { + const ordinaryTargetIds = [ + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ].filter((targetId): targetId is string => targetId !== null); + if ( + ordinaryTargetIds.length > 0 && + activeSearchHighlight && + !ordinaryTargetIds.includes(activeSearchHighlight.messageId) + ) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + } + return; + } + if (appliedSearchActivationIdRef.current === searchHighlight.activationId) { + return; + } + + appliedSearchActivationIdRef.current = searchHighlight.activationId; + setActiveSearchHighlight(searchHighlight); + }, [ + activeSearchHighlight, + searchHighlight, + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ]); + + // Reset spliced target events when the channel changes. 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 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(channelId); + React.useEffect(() => { + if (previousResetKeyRef.current === channelId) return; + previousResetKeyRef.current = channelId; + appliedSearchActivationIdRef.current = null; setTargetMessageEvents([]); - }, [channelId, selectedPostId]); + setActiveSearchHighlight(null); + }, [channelId]); React.useEffect(() => { let isCancelled = false; @@ -234,6 +281,8 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetSearchMessageId={activeSearchHighlight?.messageId} + targetSearchQuery={activeSearchHighlight?.query} /> ); } diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 1025cc1e89..8fcab41817 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -33,6 +34,9 @@ function ForumPostRouteComponent() { usePreviewFeatureWarning("forum"); const { channelId, postId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); const isHuddleTranscript = huddleWindowChannelId() !== null; return ( @@ -74,6 +79,7 @@ function ChannelRouteComponent() { { + assert.deepEqual( + selectSearchHighlightRouteState({ state: { searchHighlight } }), + searchHighlight, + ); +}); + +test("target cleanup without highlight state preserves the selection", () => { + assert.equal(selectSearchHighlightRouteState({ state: {} }), undefined); +}); + +test("ordinary navigation explicitly clears the selection", () => { + assert.equal( + selectSearchHighlightRouteState({ state: { searchHighlight: null } }), + null, + ); +}); + +test("ignores malformed router state", () => { + assert.equal( + selectSearchHighlightRouteState({ + state: { searchHighlight: { messageId: "message", query: "mentions" } }, + }), + undefined, + ); +}); diff --git a/desktop/src/app/routes/searchHighlightRouteState.ts b/desktop/src/app/routes/searchHighlightRouteState.ts new file mode 100644 index 0000000000..4fcc01c8d3 --- /dev/null +++ b/desktop/src/app/routes/searchHighlightRouteState.ts @@ -0,0 +1,17 @@ +import { + parseSearchHighlightNavigation, + type SearchHighlightNavigation, +} from "@/app/navigation/searchHighlightNavigation"; + +export function selectSearchHighlightRouteState(location: { + state: unknown; +}): SearchHighlightNavigation | null | undefined { + const state = location.state as { searchHighlight?: unknown } | undefined; + if (!(state && "searchHighlight" in state)) { + return undefined; + } + if (state.searchHighlight === null) { + return null; + } + return parseSearchHighlightNavigation(state.searchHighlight) ?? undefined; +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 4086a4e1c1..19bb21b62f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -49,6 +49,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; +import { useSearchHighlightProps } from "@/features/channels/ui/useSearchHighlightProps"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -152,6 +153,8 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + targetSearchMessageId, + targetSearchQuery, threadAllMessages, threadHeadMessage, threadMessages, @@ -176,6 +179,10 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, ); const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const searchHighlightProps = useSearchHighlightProps( + targetSearchMessageId, + targetSearchQuery, + ); const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false); const isNonMemberView = @@ -194,8 +201,6 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear only the auto-send key so thread state survives deferred submission; - // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -252,9 +257,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const timeoutState = useTimeoutState(); - // A moderation DM (1:1 with the relay identity) is read-only for the member; - // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → - // ordinary DM, composer enabled. const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm"); const isModerationDmChannel = isModerationDm( activeChannel ?? null, @@ -339,10 +341,6 @@ export const ChannelPane = React.memo(function ChannelPane({ !isMainDeferredEditPending && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; - // Unified working set for the composer bar: observer-derived turns primary, - // bot typing fallback (both folded together by agentWorkingSignal). This is - // what makes the bar show for an agent whose observer stream is live but - // whose typing signal never arrives — and vice versa. const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( activeChannel?.id ?? null, ); @@ -696,6 +694,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} + {...searchHighlightProps.timeline} targetMessageId={targetMessageId} splitThreadPanelOpen={ useSplitAuxiliaryPane && @@ -894,6 +893,7 @@ export const ChannelPane = React.memo(function ChannelPane({ replyTargetMessage={threadReplyTargetMessage} scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} + {...searchHighlightProps.thread} threadHead={threadHeadMessage} videoReviewPresentation={threadVideoReviewPresentation} widthPx={threadPanelWidthPx} @@ -940,10 +940,6 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : activeChannel && selectedAgent ? ( (() => { - // When the panel was opened from a different channel than the - // currently active one, re-scope it to the active channel so - // that both the content/header AND channel-backed actions (e.g. - // Stop current turn) operate on the same channel object. const effectiveAgentSessionChannelId = openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 0296822168..d1b88ae3a6 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -181,6 +181,10 @@ export type ChannelPaneProps = { threadReplyUnreadCounts?: ReadonlyMap; threadFirstUnreadReplyId?: string | null; targetMessageId: string | null; + /** Exact clicked result id, including a reply routed into the thread panel. */ + targetSearchMessageId?: string | null; + /** Search text to highlight within the clicked result. */ + targetSearchQuery?: string; typingPubkeys: string[]; isFollowingThread?: boolean; onFollowThread?: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 240a9ad70c..fe0c4d23b7 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -7,24 +7,14 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; -import { - useChannelMembersQuery, - useJoinChannelMutation, -} from "@/features/channels/hooks"; -import { - MSG_PREFIX, - THREAD_PREFIX, -} from "@/features/channels/readState/readStateFormat"; +import * as channelHooks from "@/features/channels/hooks"; +import * as readStateFormat from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; -import { - useManagedAgentsQuery, - usePersonasQuery, - useRelayAgentsQuery, -} from "@/features/agents/hooks"; +import * as agentHooks from "@/features/agents/hooks"; import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; @@ -44,10 +34,7 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { - getThreadReference, - isThreadReply, -} from "@/features/messages/lib/threading"; +import * as threading from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -62,10 +49,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import { - useHuddleChannelMessages, - useIsHuddleTranscript, -} from "@/features/channels/ui/useHuddleChannelMessages"; +import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; @@ -89,6 +73,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; +import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, @@ -101,6 +86,7 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, + ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); @@ -171,7 +157,8 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; - const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); + const isHuddleTranscript = + huddleMessages.useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const requireThreadEditResolutionRef = React.useRef<() => boolean>( () => true, @@ -214,7 +201,7 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (getThreadReference(messages[index].tags).parentId === null) + if (threading.getThreadReference(messages[index].tags).parentId === null) return messages[index]; } return null; @@ -233,7 +220,8 @@ export function ChannelScreen({ return; } setContextParentResolver((contextId) => - contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX) + contextId.startsWith(readStateFormat.THREAD_PREFIX) || + contextId.startsWith(readStateFormat.MSG_PREFIX) ? activeChannelId : null, ); @@ -253,13 +241,14 @@ export function ChannelScreen({ const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); - const joinChannelMutation = useJoinChannelMutation(activeChannelId); + const joinChannelMutation = + channelHooks.useJoinChannelMutation(activeChannelId); const { resolvedMessages, threadSummaries, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, - } = useHuddleChannelMessages({ + } = huddleMessages.useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -305,9 +294,11 @@ export function ChannelScreen({ : [], [activeChannel], ); - const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); + const channelMembersQuery = channelHooks.useChannelMembersQuery( + activeChannel?.id ?? null, + ); const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentsQuery = agentHooks.useManagedAgentsQuery(); const managedAgents = managedAgentsQuery.data ?? []; const welcomeGuideAgent = React.useMemo( () => pickWelcomeGuideAgent(managedAgents), @@ -318,7 +309,7 @@ export function ChannelScreen({ currentIdentity, welcomeGuideAgent, }); - const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgentsQuery = agentHooks.useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -385,7 +376,7 @@ export function ChannelScreen({ } return pubkeys; }, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]); - const personasQuery = usePersonasQuery(); + const personasQuery = agentHooks.usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personaById = new Map( @@ -502,7 +493,8 @@ export function ChannelScreen({ editMessageMutation, editTargetId, editTargetIsThreadReply: - editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), + editTargetMessage !== null && + threading.isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -632,9 +624,6 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - // A persisted head only counts as hydrated when it has rows to paint - // (channelHeadCache.ts), so this bypass never settles onto an empty - // placeholder while the authoritative refresh is still in flight. hasSettledThisChannel || (activeChannelId !== null && hasPersistedHydratedChannel(queryClient, activeChannelId)), @@ -825,27 +814,30 @@ export function ChannelScreen({ > {activeChannel ? ( activeChannel.channelType === "forum" ? ( - + searchForwarding.renderSearchAwareForum( + , + searchTarget, + ) ) : ( } > - - knownAgentPubkeys.has(pubkey) || - !!messageProfiles?.[pubkey]?.isAgent, - ) - : null - } - followThreadById={followThread} - unfollowThreadById={unfollowThread} - isFollowingThreadById={isFollowingThread} - isMessageUnreadById={isMessageUnread} - isFollowingThread={isNotifiedForEffectiveThread} - isSending={sendMessageMutation.isPending} - isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} - threadSummaries={threadSummaries} - huddleThreadRepliesError={huddleThreadRepliesError} - onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} - onCancelEdit={handleCancelEdit} - onCancelThreadReply={handleCancelThreadReply} - onChannelManagementDeleted={handleChannelManagementDeleted} - onFollowThread={ - effectiveOpenThreadHeadId != null && - !isNotifiedForEffectiveThread - ? () => followThread(effectiveOpenThreadHeadId) - : undefined - } - onUnfollowThread={ - effectiveOpenThreadHeadId != null && - isNotifiedForEffectiveThread - ? () => unfollowThread(effectiveOpenThreadHeadId) - : undefined - } - onCloseAgentSession={handleCloseAgentSession} - onBackFromAgentSession={ - hasAgentSessionReturnTarget - ? handleBackFromAgentSession - : undefined - } - onCloseChannelManagement={handleCloseChannelManagement} - onCloseThread={handleCloseThread} - onDelete={ - activeChannel?.archivedAt ? undefined : handleDelete - } - onEdit={activeChannel?.archivedAt ? undefined : handleEdit} - onEditSave={ - activeChannel?.archivedAt ? undefined : handleEditSave - } - onMarkUnread={handleMessageMarkUnread} - onMarkRead={handleMessageMarkRead} - onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} - onOpenDm={handleOpenDm} - onOpenProfilePanel={handleOpenProfilePanel} - onResetThreadPanelWidth={handleThreadPanelWidthReset} - onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} - onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} - onSendToChannel={handleSendToChannel} - onSendVideoReviewComment={effectiveSendVideoReviewComment} - onSendThreadReply={handleSendThreadReply} - onThreadScrollTargetResolved={() => - setThreadScrollTargetId(null) - } - onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={() => - clearMessageRouteTarget({ replace: true }) - } - onToggleReaction={effectiveToggleReaction} - openAgentSessionChannelId={openAgentSessionChannelId} - openAgentSessionPubkey={openAgentSessionPubkey} - openThreadHeadId={effectiveOpenThreadHeadId} - shouldShowThreadSkeleton={shouldShowThreadSkeleton} - onProfilePanelViewChange={setProfilePanelView} - onProfilePanelTabChange={setProfilePanelTab} - profilePanelPubkey={profilePanelPubkey} - profilePanelTab={profilePanelTab} - profilePanelView={profilePanelView} - personaLookup={personaLookup} - profiles={messageProfiles} - ownerProfiles={messageOwnerProfiles} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} - threadAllMessages={displayedThreadAllMessages} - threadHeadMessage={displayedThreadHeadMessage} - threadMessages={displayedThreadMessages} - threadMessagesPending={threadRepliesQuery.isPending} - threadMessagesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadPanelWidthPx={threadPanelWidthPx} - threadTypingPubkeys={threadTypingPubkeys} - threadReplyTargetMessage={displayedThreadReplyTargetMessage} - threadScrollTargetId={threadScrollTargetId} - threadUnreadCounts={threadUnreadCounts} - threadReplyUnreadCounts={threadReplyUnreadCounts} - threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} - isJoining={joinChannelMutation.isPending} - onJoinChannel={joinChannelMutation.mutateAsync} - typingPubkeys={humanTypingPubkeys} - /> + {searchForwarding.renderSearchAwareChannel( + + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) + : null + } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isMessageUnreadById={isMessageUnread} + isFollowingThread={isNotifiedForEffectiveThread} + isSending={sendMessageMutation.isPending} + isSinglePanelView={isSinglePanelView} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} + onCancelEdit={handleCancelEdit} + onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={handleChannelManagementDeleted} + onFollowThread={ + effectiveOpenThreadHeadId != null && + !isNotifiedForEffectiveThread + ? () => followThread(effectiveOpenThreadHeadId) + : undefined + } + onUnfollowThread={ + effectiveOpenThreadHeadId != null && + isNotifiedForEffectiveThread + ? () => unfollowThread(effectiveOpenThreadHeadId) + : undefined + } + onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } + onCloseChannelManagement={handleCloseChannelManagement} + onCloseThread={handleCloseThread} + onDelete={ + activeChannel?.archivedAt ? undefined : handleDelete + } + onEdit={activeChannel?.archivedAt ? undefined : handleEdit} + onEditSave={ + activeChannel?.archivedAt ? undefined : handleEditSave + } + onMarkUnread={handleMessageMarkUnread} + onMarkRead={handleMessageMarkRead} + onExpandThreadReplies={handleExpandThreadReplies} + onOpenAgentSession={handleOpenAgentSession} + onOpenDm={handleOpenDm} + onOpenProfilePanel={handleOpenProfilePanel} + onResetThreadPanelWidth={handleThreadPanelWidthReset} + onCloseProfilePanel={handleCloseProfilePanel} + onOpenThread={handleOpenThreadAndCloseAgentSession} + onSelectThreadReplyTarget={handleSelectThreadReplyTarget} + onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} + onSendVideoReviewComment={effectiveSendVideoReviewComment} + onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetResolved={() => + setThreadScrollTargetId(null) + } + onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } + onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} + openAgentSessionPubkey={openAgentSessionPubkey} + openThreadHeadId={effectiveOpenThreadHeadId} + shouldShowThreadSkeleton={shouldShowThreadSkeleton} + onProfilePanelViewChange={setProfilePanelView} + onProfilePanelTabChange={setProfilePanelTab} + profilePanelPubkey={profilePanelPubkey} + profilePanelTab={profilePanelTab} + profilePanelView={profilePanelView} + personaLookup={personaLookup} + profiles={messageProfiles} + ownerProfiles={messageOwnerProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} + threadHeadMessage={displayedThreadHeadMessage} + threadMessages={displayedThreadMessages} + threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadPanelWidthPx={threadPanelWidthPx} + threadTypingPubkeys={threadTypingPubkeys} + threadReplyTargetMessage={displayedThreadReplyTargetMessage} + threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} + isJoining={joinChannelMutation.isPending} + onJoinChannel={joinChannelMutation.mutateAsync} + typingPubkeys={humanTypingPubkeys} + />, + searchTarget, + )} ) ) : ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5..a64937a74b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -22,4 +22,8 @@ export type ChannelScreenProps = { targetForumReplyId: string | null; targetMessageEvents: RelayEvent[]; targetMessageId: string | null; + /** Exact clicked result id, retained after route target cleanup. */ + targetSearchMessageId?: string; + /** Search text to highlight within the opened result message. */ + targetSearchQuery?: string; }; diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx index 3565502616..7826938665 100644 --- a/desktop/src/features/channels/ui/ForumChannelContent.tsx +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -42,6 +42,8 @@ type ForumChannelContentProps = { profilePanelView: ProfilePanelView; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; /** @@ -71,6 +73,8 @@ export function ForumChannelContent({ profilePanelView, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumChannelContentProps) { return ( <> @@ -88,6 +92,8 @@ export function ForumChannelContent({ onSelectPost={onSelectPost} selectedPostId={selectedPostId} targetReplyId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} /> diff --git a/desktop/src/features/channels/ui/searchTargetForwarding.tsx b/desktop/src/features/channels/ui/searchTargetForwarding.tsx new file mode 100644 index 0000000000..925156258c --- /dev/null +++ b/desktop/src/features/channels/ui/searchTargetForwarding.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import type { ForumChannelContent } from "./ForumChannelContent"; +import type { GuardedChannelPane } from "./GuardedChannelPane"; +import type { ChannelScreenProps } from "./ChannelScreen.types"; + +type SearchTarget = Pick< + ChannelScreenProps, + "targetSearchMessageId" | "targetSearchQuery" +>; + +export const renderSearchAwareForum = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); + +export const renderSearchAwareChannel = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); diff --git a/desktop/src/features/channels/ui/useSearchHighlightProps.ts b/desktop/src/features/channels/ui/useSearchHighlightProps.ts new file mode 100644 index 0000000000..b506fee182 --- /dev/null +++ b/desktop/src/features/channels/ui/useSearchHighlightProps.ts @@ -0,0 +1,18 @@ +import * as React from "react"; + +export function useSearchHighlightProps( + messageId: string | null | undefined, + query: string | undefined, +) { + const searchMatchingMessageIds = React.useMemo( + () => (messageId ? new Set([messageId]) : undefined), + [messageId], + ); + return React.useMemo( + () => ({ + thread: { searchMessageId: messageId, searchQuery: query }, + timeline: { searchMatchingMessageIds, searchQuery: query }, + }), + [messageId, query, searchMatchingMessageIds], + ); +} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 7bf40fbffe..348fa15700 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -41,6 +41,8 @@ type ForumThreadPanelProps = { canDeletePost?: boolean; isDeletingPost?: boolean; targetEventId?: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDeleteReply( @@ -57,12 +59,14 @@ function ReplyRow({ profiles, channelNames, onDelete, + searchQuery, }: { reply: ThreadReply; currentPubkey?: string; profiles?: UserProfileLookup; channelNames?: string[]; onDelete?: (eventId: string) => void; + searchQuery?: string; }) { const replyAuthorLabel = resolveUserLabel({ pubkey: reply.pubkey, @@ -122,6 +126,7 @@ function ReplyRow({ imetaByUrl={parseImetaTags(reply.tags)} mentionNames={replyMentionNames} mentionPubkeysByName={replyMentionPubkeysByName} + searchQuery={searchQuery} /> @@ -143,6 +148,8 @@ export function ForumThreadPanel({ canDeletePost, isDeletingPost, targetEventId, + targetSearchMessageId, + targetSearchQuery, }: ForumThreadPanelProps) { const scrollRef = React.useRef(null); const { channels } = useChannelNavigation(); @@ -268,6 +275,11 @@ export function ForumThreadPanel({ imetaByUrl={parseImetaTags(post.tags)} mentionNames={postMentionNames} mentionPubkeysByName={postMentionPubkeysByName} + searchQuery={ + targetSearchMessageId === post.eventId + ? targetSearchQuery + : undefined + } /> @@ -286,6 +298,11 @@ export function ForumThreadPanel({ onDelete={onDeleteReply} profiles={profiles} reply={reply} + searchQuery={ + targetSearchMessageId === reply.eventId + ? targetSearchQuery + : undefined + } /> ))} diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 9efada5549..670ee4e6df 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -30,6 +30,8 @@ type ForumViewProps = { onTargetReached?: (messageId: string) => void; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDelete(postPubkey: string, currentPubkey?: string): boolean { @@ -47,6 +49,8 @@ export function ForumView({ onTargetReached, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumViewProps) { const [isComposerOpen, setIsComposerOpen] = React.useState(false); const postsScrollRef = React.useRef(null); @@ -156,6 +160,8 @@ export function ForumView({ onTargetReached={onTargetReached} profiles={profiles} targetEventId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} thread={threadQuery.data} /> ); diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx index 71a3460ce8..f1ff265339 100644 --- a/desktop/src/features/messages/ui/DiffMessage.tsx +++ b/desktop/src/features/messages/ui/DiffMessage.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { FileDiff, Maximize2 } from "lucide-react"; import { getDiffTitleBadge } from "@/features/messages/lib/parseDiff"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { isSafeUrl } from "@/shared/lib/url"; import { Button } from "@/shared/ui/button"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; @@ -14,6 +15,7 @@ type DiffMessageProps = { filePath?: string; commitSha?: string; description?: string; + searchQuery?: string; truncated?: boolean; onExpand?: () => void; }; @@ -32,6 +34,7 @@ export default function DiffMessage({ filePath, commitSha, description, + searchQuery, truncated, onExpand, }: DiffMessageProps) { @@ -116,7 +119,7 @@ export default function DiffMessage({ {description && (
- {description} +
)} @@ -126,6 +129,7 @@ export default function DiffMessage({ className="p-3" content={content} fallbackFilePath={filePath} + searchQuery={searchQuery} viewType="unified" /> diff --git a/desktop/src/features/messages/ui/DiffViewer.tsx b/desktop/src/features/messages/ui/DiffViewer.tsx index 1444cf9977..ed6703f66c 100644 --- a/desktop/src/features/messages/ui/DiffViewer.tsx +++ b/desktop/src/features/messages/ui/DiffViewer.tsx @@ -2,6 +2,8 @@ import { Diff, Hunk, type ViewType } from "react-diff-view"; import "react-diff-view/style/index.css"; import { useMemo } from "react"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { countDiffFileChanges, DIFF_TYPE_LABELS, @@ -18,6 +20,7 @@ type DiffViewerProps = { fallbackFilePath?: string; viewType?: ViewType; className?: string; + searchQuery?: string; }; function FileChangeBadge({ @@ -46,16 +49,20 @@ export function DiffViewer({ fallbackFilePath, viewType = "unified", className, + searchQuery, }: DiffViewerProps) { const { files, parseError } = useMemo( () => parseUnifiedDiff(content), [content], ); + const searchPreview = searchQuery + ? buildSearchResultPreview(content, searchQuery, 160) + : null; if (parseError) { return (
-        {content}
+        
       
); } @@ -70,6 +77,17 @@ export function DiffViewer({ return (
+ {searchPreview ? ( +
+          
+        
+ ) : null}
{files.map((file) => { const label = getDiffFileLabel(file, fallbackFilePath); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 536631b02d..0573a3186b 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -396,6 +396,7 @@ export const MessageRow = React.memo( setExpandedDiffId(message.id); }} repoUrl={getTag("repo")} + searchQuery={searchQuery} truncated={getTag("truncated") === "true"} /> @@ -417,6 +418,7 @@ export const MessageRow = React.memo( fallbackText={waveMessage.fallbackText} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} + searchQuery={searchQuery} /> ); } diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 01fbcda7ff..d3b1394829 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -81,6 +81,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { onScrollTargetResolved: () => void; onScrollTargetSettled?: (messageId: string) => void; scrollTargetHighlights?: boolean; + searchMessageId?: string | null; + searchQuery?: string; onSelectReplyTarget: (message: TimelineMessage) => void; onSend: ( content: string, @@ -185,6 +187,8 @@ export function MessageThreadPanel({ replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, + searchMessageId, + searchQuery, threadHead, videoReviewPresentation, threadReplies, @@ -562,6 +566,9 @@ export function MessageThreadPanel({ onUnfollowThread ? (_msg) => onUnfollowThread() : undefined } profiles={profiles} + searchQuery={ + searchMessageId === threadHead.id ? searchQuery : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( threadHead.id, @@ -725,6 +732,11 @@ export function MessageThreadPanel({ onSendToChannel={stableSendToChannel} onToggleReaction={onToggleReaction} profiles={profiles} + searchQuery={ + searchMessageId === entry.message.id + ? searchQuery + : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( entry.message.id, diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx index f782326295..9c1a6feeee 100644 --- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx +++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { channelsQueryKey } from "@/features/channels/hooks"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useHuddle } from "@/features/huddle"; import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { @@ -20,6 +21,7 @@ type WaveMessageAttachmentProps = { fallbackText: string; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; + searchQuery?: string; }; export function WaveMessageAttachment({ @@ -27,6 +29,7 @@ export function WaveMessageAttachment({ fallbackText, huddleMemberPubkeys = [], huddleMemberPubkeysPending = false, + searchQuery, }: WaveMessageAttachmentProps) { const queryClient = useQueryClient(); const { isStarting, startHuddle } = useHuddle(); @@ -68,7 +71,12 @@ export function WaveMessageAttachment({ 👋 - {fallbackText} + + + Start a huddle to talk to them. diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs new file mode 100644 index 0000000000..4f9651a75d --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts"; + +test("splitSearchMatches highlights every case-insensitive lexeme match", () => { + assert.deepEqual(splitSearchMatches("Mentions and mentions", "mentions"), [ + { isMatch: true, key: "0-8", text: "Mentions" }, + { isMatch: false, key: "8-5", text: " and " }, + { isMatch: true, key: "13-8", text: "mentions" }, + ]); +}); + +test("splitSearchMatches normalizes punctuation into search lexemes", () => { + assert.deepEqual(splitSearchMatches("foo bar release", "foo-bar"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "bar" }, + { isMatch: false, key: "7-8", text: " release" }, + ]); +}); + +test("splitSearchMatches keeps completed tokens on lexeme boundaries", () => { + assert.deepEqual( + splitSearchMatches("projectile notes about project planning", "project pl"), + [ + { + isMatch: false, + key: "0-23", + text: "projectile notes about ", + }, + { isMatch: true, key: "23-7", text: "project" }, + { isMatch: false, key: "30-1", text: " " }, + { isMatch: true, key: "31-2", text: "pl" }, + { isMatch: false, key: "33-6", text: "anning" }, + ], + ); +}); + +test("splitSearchMatches preserves exact and prefix modes for a repeated term", () => { + assert.deepEqual(splitSearchMatches("foo foobar", "foo foo"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "foo" }, + { isMatch: false, key: "7-3", text: "bar" }, + ]); +}); + +test("splitSearchMatches highlights non-adjacent prefix-search terms", () => { + assert.deepEqual(splitSearchMatches("agent status mentions", "agent ment"), [ + { isMatch: true, key: "0-5", text: "agent" }, + { isMatch: false, key: "5-8", text: " status " }, + { isMatch: true, key: "13-4", text: "ment" }, + { isMatch: false, key: "17-4", text: "ions" }, + ]); +}); + +test("splitSearchMatches keeps one-character prefixes on lexeme boundaries", () => { + assert.deepEqual(splitSearchMatches("A plan", "a"), [ + { isMatch: true, key: "0-1", text: "A" }, + { isMatch: false, key: "1-5", text: " plan" }, + ]); +}); + +test("splitSearchMatches maps expanding lowercase prefixes to original spans", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "İs"), [ + { isMatch: true, key: "0-2", text: "İs" }, + { isMatch: false, key: "2-14", text: "tanbul release" }, + ]); + assert.deepEqual(splitSearchMatches("İstanbul release", "İst"), [ + { isMatch: true, key: "0-3", text: "İst" }, + { isMatch: false, key: "3-13", text: "anbul release" }, + ]); +}); + +test("splitSearchMatches does not split a character whose lowercase form expands", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "i"), [ + { isMatch: true, key: "0-1", text: "İ" }, + { isMatch: false, key: "1-15", text: "stanbul release" }, + ]); +}); + +test("splitSearchMatches preserves UTF-16 boundaries for supplementary letters", () => { + assert.deepEqual(splitSearchMatches("𐐀İstanbul release", "𐐨İs"), [ + { isMatch: true, key: "0-4", text: "𐐀İs" }, + { isMatch: false, key: "4-14", text: "tanbul release" }, + ]); +}); + +test("buildSearchResultPreview keeps a late match visible", () => { + const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`; + const preview = buildSearchResultPreview(content, "mentions", 96); + + assert.equal(preview.length <= 96, true); + assert.match(preview, /mentions/i); + assert.match(preview, /^\.\.\./); + assert.match(preview, /\.\.\.$/); +}); + +test("buildSearchResultPreview ignores an invalid completed-token substring", () => { + const content = `${"projectile filler ".repeat(20)}project planning release notes`; + const preview = buildSearchResultPreview(content, "project pl", 80); + + assert.match(preview, /project planning/); + assert.match(preview, /^\.\.\./); +}); + +test("buildSearchResultPreview keeps the existing leading excerpt without a match", () => { + assert.equal( + buildSearchResultPreview("abcdefghijklmnopqrstuvwxyz", "missing", 10), + "abcdefg...", + ); +}); diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts new file mode 100644 index 0000000000..f74d1a2678 --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -0,0 +1,201 @@ +export type SearchMatchPart = { + isMatch: boolean; + key: string; + text: string; +}; + +type SearchHighlightTerm = { + isPrefix: boolean; + value: string; +}; + +type TextLexeme = { + end: number; + normalized: string; + start: number; +}; + +// PostgreSQL's `simple` text-search configuration breaks ordinary punctuation +// into lexemes (for example, `foo-bar` contributes `foo` and `bar`). Keep the +// desktop highlighter on those lexical boundaries rather than treating raw +// whitespace tokens as unrestricted substrings. +const LEXEME_PATTERN = /[\p{L}\p{N}]+/gu; + +function extractLexemes(value: string): string[] { + return Array.from(value.matchAll(LEXEME_PATTERN), (match) => + match[0].toLowerCase(), + ); +} + +function getSearchHighlightMatchers(query: string): SearchHighlightTerm[] { + const rawTokens = query.trim().split(/\s+/).filter(Boolean); + const matchers: SearchHighlightTerm[] = []; + + rawTokens.forEach((rawToken, tokenIndex) => { + const isPrefix = tokenIndex === rawTokens.length - 1; + for (const value of extractLexemes(rawToken)) { + matchers.push({ isPrefix, value }); + } + }); + + // Deduplicate repeated constraints without collapsing exact and prefix modes: + // `foo foo` asks Postgres for both an exact `foo` and a `foo:*` lexeme. + const deduped = new Map(); + for (const matcher of matchers) { + deduped.set( + `${matcher.isPrefix ? "prefix" : "exact"}:${matcher.value}`, + matcher, + ); + } + return [...deduped.values()].sort( + (left, right) => right.value.length - left.value.length, + ); +} + +/** + * Lexemes used by desktop prefix search after punctuation normalization. + * Completed whitespace-delimited tokens match exactly; only lexemes from the + * trailing token match prefixes. + */ +export function getSearchHighlightTerms(query: string): string[] { + return getSearchHighlightMatchers(query).map((matcher) => matcher.value); +} + +function getTextLexemes(text: string): TextLexeme[] { + return Array.from(text.matchAll(LEXEME_PATTERN), (match) => ({ + end: (match.index ?? 0) + match[0].length, + normalized: match[0].toLowerCase(), + start: match.index ?? 0, + })); +} + +function getOriginalPrefixLength( + original: string, + normalizedLength: number, +): number { + let normalizedOffset = 0; + let originalOffset = 0; + + for (const character of original) { + normalizedOffset += character.toLowerCase().length; + originalOffset += character.length; + if (normalizedOffset >= normalizedLength) { + return originalOffset; + } + } + + return original.length; +} + +function getMatchSpans( + text: string, + query: string, +): Array<{ end: number; start: number }> { + const matchers = getSearchHighlightMatchers(query); + if (matchers.length === 0) { + return []; + } + + const spans: Array<{ end: number; start: number }> = []; + for (const lexeme of getTextLexemes(text)) { + const exactMatch = matchers.find( + (matcher) => !matcher.isPrefix && matcher.value === lexeme.normalized, + ); + if (exactMatch) { + spans.push({ start: lexeme.start, end: lexeme.end }); + continue; + } + + const prefixMatch = matchers.find( + (matcher) => + matcher.isPrefix && lexeme.normalized.startsWith(matcher.value), + ); + if (prefixMatch) { + const originalLexeme = text.slice(lexeme.start, lexeme.end); + spans.push({ + start: lexeme.start, + end: + lexeme.start + + getOriginalPrefixLength(originalLexeme, prefixMatch.value.length), + }); + } + } + + return spans; +} + +/** Split text around case-insensitive lexeme/prefix matches of the query. */ +export function splitSearchMatches( + text: string, + query: string, +): SearchMatchPart[] { + const spans = getMatchSpans(text, query); + if (spans.length === 0) { + return [{ isMatch: false, key: "0", text }]; + } + + const parts: SearchMatchPart[] = []; + let offset = 0; + for (const span of spans) { + if (span.start > offset) { + parts.push({ + isMatch: false, + key: `${offset}-${span.start - offset}`, + text: text.slice(offset, span.start), + }); + } + parts.push({ + isMatch: true, + key: `${span.start}-${span.end - span.start}`, + text: text.slice(span.start, span.end), + }); + offset = span.end; + } + if (offset < text.length) { + parts.push({ + isMatch: false, + key: `${offset}-${text.length - offset}`, + text: text.slice(offset), + }); + } + return parts; +} + +/** + * Build a compact result excerpt that keeps the first matching search term + * visible. Context is biased before the match so the excerpt still reads like + * a sentence while avoiding a match that is clipped offscreen. + */ +export function buildSearchResultPreview( + content: string, + query: string, + maxLength = 96, +): string { + const text = content.trim(); + if (!text) { + return "No message body."; + } + if (text.length <= maxLength) { + return text; + } + + const matchIndex = getMatchSpans(text, query)[0]?.start ?? -1; + if (matchIndex < 0) { + return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; + } + + const contextBefore = Math.min(32, Math.floor(maxLength / 3)); + let start = Math.max(0, matchIndex - contextBefore); + const end = Math.min(text.length, start + maxLength); + + if (end === text.length) { + start = Math.max(0, end - maxLength); + } + + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + const available = Math.max(0, maxLength - prefix.length - suffix.length); + const excerpt = text.slice(start, start + available).trim(); + + return `${prefix}${excerpt}${suffix}`; +} diff --git a/desktop/src/features/search/ui/HighlightedSearchText.tsx b/desktop/src/features/search/ui/HighlightedSearchText.tsx new file mode 100644 index 0000000000..51745f8873 --- /dev/null +++ b/desktop/src/features/search/ui/HighlightedSearchText.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + +export function HighlightedSearchText({ + query, + text, +}: { + query: string; + text: string; +}) { + return splitSearchMatches(text, query).map((part) => + part.isMatch ? ( + + {part.text} + + ) : ( + {part.text} + ), + ); +} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 963ae6cf42..70365497bc 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -3,6 +3,8 @@ import * as React from "react"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { getMinimumSearchQueryLength } from "@/features/search/hooks"; +import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; import { useSearchResults } from "@/features/search/useSearchResults"; import { resultIcon, @@ -15,6 +17,7 @@ import { getChannelScopeLabel, SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -36,7 +39,7 @@ type TopbarSearchProps = { currentChannelId?: string | null; focusRequest?: number; onOpenChannel: (channelId: string) => void; - onOpenResult: (hit: SearchHit) => void; + onOpenResult: (hit: SearchHit, query: string) => void; onOpenUser?: (user: UserSearchResult) => void | Promise; onBrowseChannels?: () => void | Promise; onCreateAgent?: () => void | Promise; @@ -72,19 +75,6 @@ type SearchHitContextLabel = { text: string; }; -function truncateResultText(content: string, maxLength = 96) { - const trimmed = content.trim(); - if (trimmed.length === 0) { - return "No message body."; - } - - if (trimmed.length <= maxLength) { - return trimmed; - } - - return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; -} - function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; @@ -436,6 +426,10 @@ export function TopbarSearch({ scopeChannelId, }); const trimmedQuery = query.trim(); + // Bind highlights to the debounced result source so stale results can never + // pair with newly typed text during the debounce window. + const resultQuery = parseSearchOperators(debouncedQuery).text; + const resultsAreCurrent = debouncedQuery === trimmedQuery; const isIconVariant = variant === "icon"; const currentChannel = currentChannelId ? (channelLookup.get(currentChannelId) ?? null) @@ -504,9 +498,10 @@ export function TopbarSearch({ ), [currentPubkeyNormalized, results], ); + const visibleSearchableResults = resultsAreCurrent ? searchableResults : []; const searchResultSections = React.useMemo( - () => groupSearchResults(searchableResults), - [searchableResults], + () => groupSearchResults(visibleSearchableResults), + [visibleSearchableResults], ); const groupedSearchResults = React.useMemo( () => searchResultSections.flatMap((section) => section.results), @@ -516,8 +511,11 @@ export function TopbarSearch({ ? scopeChannel ? [] : suggestionResults - : groupedSearchResults; + : resultsAreCurrent + ? groupedSearchResults + : []; const isSearchLoading = + (!isShowingSuggestions && !resultsAreCurrent) || isWaitingOnFromResolution || searchQuery.isLoading || fuzzyUserCandidatesQuery.isLoading || @@ -581,7 +579,7 @@ export function TopbarSearch({ return; } - onOpenResult(result.hit); + onOpenResult(result.hit, resultQuery); }, [ onBrowseChannels, @@ -592,6 +590,7 @@ export function TopbarSearch({ onOpenUser, openAfterExit, setQuery, + resultQuery, ], ); @@ -697,7 +696,7 @@ export function TopbarSearch({ ? result.action.description : result.kind === "user" ? getUserSecondaryLabel(result.user) - : truncateResultText(result.hit.content); + : buildSearchResultPreview(result.hit.content, resultQuery); const trailingLabel = result.kind === "channel" ? getChannelSuggestionMeta(result.channel) @@ -771,7 +770,7 @@ export function TopbarSearch({ ) : null} {preview ? ( - {preview} + ) : null} @@ -873,12 +872,13 @@ export function TopbarSearch({
) - ) : isSearchLoading && searchableResults.length === 0 ? ( + ) : isSearchLoading && visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}
- ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( + ) : searchQuery.error instanceof Error && + visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

- ) : searchableResults.length === 0 ? ( + ) : visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

{ diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 8d626c4593..ab35b8b598 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -88,7 +88,7 @@ export type AppSidebarProps = { onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; /** Full channel set for global search, including channels outside the joined sidebar list. */ searchChannels: Channel[]; searchFocusRequests: readonly [global: number, channel: number]; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 1e0db29cac..a9bf7058eb 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -30,7 +30,7 @@ type AppSidebarPinnedHeaderProps = { onCreateAgent: () => void; onCreateChannel: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; onSelectChannel: (channelId: string) => void; searchChannels: Channel[]; searchFocusRequest: number; diff --git a/desktop/src/shared/lib/rehypeSearchHighlight.ts b/desktop/src/shared/lib/rehypeSearchHighlight.ts index df7f1de292..e3de49a664 100644 --- a/desktop/src/shared/lib/rehypeSearchHighlight.ts +++ b/desktop/src/shared/lib/rehypeSearchHighlight.ts @@ -6,6 +6,9 @@ * ReactMarkdown's architecture — no post-render tree walking needed. */ +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + // Minimal HAST types — matches the pattern in rehypeImageGallery.ts. interface HastText { type: "text"; @@ -34,45 +37,32 @@ function isText(node: HastNode): node is HastText { return node.type === "text"; } -function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - export default function rehypeSearchHighlight({ query }: { query: string }) { return (tree: HastRoot) => { - const trimmed = query.trim(); - if (trimmed.length < 2) return; - - const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "i"); - function walk(nodes: HastNode[]): HastNode[] { const result: HastNode[] = []; for (const node of nodes) { if (isText(node)) { - const parts = node.value.split(pattern); - if (parts.length === 1) { + const parts = splitSearchMatches(node.value, query); + if (!parts.some((part) => part.isMatch)) { result.push(node); continue; } - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part) continue; - - if (i % 2 === 1) { - // Odd indices from split-with-capture are always the match. + for (const part of parts) { + if (part.isMatch) { result.push({ type: "element", tagName: "mark", properties: { - className: - "rounded-xs bg-primary/20 text-foreground dark:bg-primary/30", + className: SEARCH_MATCH_HIGHLIGHT_CLASS, + "data-search-match": "true", }, - children: [{ type: "text", value: part }], + children: [{ type: "text", value: part.text }], }); } else { - result.push({ type: "text", value: part }); + result.push({ type: "text", value: part.text }); } } } else if (isElement(node)) { diff --git a/desktop/src/shared/lib/searchHighlightStyle.ts b/desktop/src/shared/lib/searchHighlightStyle.ts new file mode 100644 index 0000000000..0ede60986c --- /dev/null +++ b/desktop/src/shared/lib/searchHighlightStyle.ts @@ -0,0 +1,3 @@ +/** Shared visual treatment for literal search matches. */ +export const SEARCH_MATCH_HIGHLIGHT_CLASS = + "rounded-xs bg-yellow-300/80 text-yellow-950 dark:bg-yellow-300/70 dark:text-yellow-950"; diff --git a/desktop/src/shared/ui/markdown/nodeCache.test.mjs b/desktop/src/shared/ui/markdown/nodeCache.test.mjs index e29abe1ee1..becca46499 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.test.mjs +++ b/desktop/src/shared/ui/markdown/nodeCache.test.mjs @@ -227,9 +227,27 @@ test("hardLineBreaks changes the parse and the cache key", () => { assert.equal(withoutBreaks, withoutBreaksAgain); }); -test("active search queries bypass the cache", () => { - clearMarkdownNodeCache(); - const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); - const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); - assert.notEqual(first, second); +test("single-character scoped search stays on lexeme boundaries", () => { + const html = renderToStaticMarkup( + renderCachedMarkdown({ ...BASE, content: "A plan", searchQuery: "a" }), + ); + + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 1); +}); + +test("active search queries bypass the cache and highlight every match", () => { + clearMarkdownNodeCache(); + const input = { + ...BASE, + content: "Bold and bold, but not code `bold`.", + searchQuery: "bold", + }; + const first = renderCachedMarkdown(input); + const second = renderCachedMarkdown(input); + const html = renderToStaticMarkup(first); + + assert.notEqual(first, second); + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2); + assert.match(html, /bg-yellow-300/); + assert.match(html, /bold<\/code>/); }); diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 549ca892ea..9e012f0549 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -94,7 +94,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { if (input.leadingInlineContent) { rehypePlugins.push(rehypeLeadingInlineContent); } - if (input.searchQuery && input.searchQuery.trim().length >= 2) { + if (input.searchQuery && input.searchQuery.trim().length >= 1) { rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]); } // Called as a plain function rather than rendered as : @@ -131,7 +131,7 @@ export function renderCachedMarkdown( // than churn the cache with per-query variants. Oversized content parses // fresh too — see MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH. if ( - (input.searchQuery && input.searchQuery.trim().length >= 2) || + (input.searchQuery && input.searchQuery.trim().length >= 1) || input.content.length > MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH ) { return buildMarkdownElement(input); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5bbc160752..0bd7bc6eec 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4418,6 +4418,15 @@ function getMockMessageStore(channelId: string): RelayEvent[] { content: "Release checklist: async feedback thread.", sig: "mocksig".repeat(20).slice(0, 128), }, + { + id: "mock-forum-offsite-thread", + pubkey: ALICE_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - 85 * 60, + kind: 45001, + tags: [["h", channelId]], + content: "Team offsite planning and travel notes.", + sig: "mocksig".repeat(20).slice(0, 128), + }, { id: "mock-forum-release-reply", pubkey: ALICE_PUBKEY, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 14fc299e9b..7902b3eace 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -346,6 +346,171 @@ test("opens sidebar search with the shortcut and loads the exact result", async ); }); +test("highlights the query in search results and the opened message", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("SHIPPED"); + + const result = page.getByTestId("search-result-mock-engineering-shipped"); + await expect(result).toBeVisible(); + await expect(result.locator("mark")).toHaveText("shipped"); + await expect(result.locator("mark")).toHaveClass(/bg-yellow-300/); + + await result.click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message).toBeVisible(); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); +}); + +test("highlights the clicked forum post when its route is already open", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await expect( + page.locator('[data-forum-event-id="mock-forum-release-thread"]'), + ).toBeVisible(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("checklist"); + + const result = page.getByTestId("search-result-mock-forum-release-thread"); + await expect(result).toBeVisible(); + await result.click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); +}); + +test("ordinary same-channel activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("shipped"); + await page.getByTestId("search-result-mock-engineering-shipped").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); + await expect(page).toHaveURL( + /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9(?:\?thread=mock-engineering-shipped)?$/, + ); + + await page.getByTestId("channel-engineering").click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary rendered channel link clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("welcome"); + await page.getByTestId("search-result-mock-general-welcome").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-general-welcome"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "Welcome", + ); + + await message.locator('[data-channel-link=""]').click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary same-forum activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(post.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary forum navigation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const releasePost = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Team offsite planning and travel notes.").click(); + await expect( + page.locator('[data-forum-event-id="mock-forum-offsite-thread"]'), + ).toBeVisible(); + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(releasePost).toBeVisible(); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveCount( + 0, + ); +}); + +test("does not expose stale search results with a newly typed query", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + const input = page.getByTestId("search-dialog-input"); + await input.fill("shipped"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toBeVisible(); + + await input.fill("mentions"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toHaveCount(0); +}); + test("opens channel matches from search", async ({ page }) => { await page.goto("/");