diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0e..468435e15ec 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 00000000000..1e05a62669f --- /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 00000000000..43d3506ec8c --- /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 74e5f108af6..02276d9eb34 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 8523340d481..6b180c33f00 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 7a21f0dfbe1..c82c4d96eea 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 d4626d2c6fa..03b08196afb 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(() => { + 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(() => { - const resetKey = `${channelId}::${selectedPostId ?? ""}`; - if (previousResetKeyRef.current === resetKey) return; - previousResetKeyRef.current = resetKey; + 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 1025cc1e89a..8fcab41817d 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 00000000000..4fcc01c8d39 --- /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 4086a4e1c1a..19bb21b62fe 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 02968221680..d1b88ae3a6b 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 240a9ad70c1..fe0c4d23b7d 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,170 +814,176 @@ export function ChannelScreen({ > {activeChannel ? ( activeChannel.channelType === "forum" ? ( - - ) : ( - - } - > - - 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} + onClosePost={onCloseForumPost} + onCloseProfilePanel={handleCloseProfilePanel} 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} + onPanelResizeStart={handleThreadPanelResizeStart} onProfilePanelTabChange={setProfilePanelTab} + onProfilePanelViewChange={setProfilePanelView} + onResetPanelWidth={handleThreadPanelWidthReset} + onSelectPost={onSelectForumPost} + panelWidthPx={threadPanelWidthPx} 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} - /> + selectedPostId={selectedForumPostId} + targetReplyId={targetForumReplyId} + />, + searchTarget, + ) + ) : ( + + } + > + {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 371af6faf5d..a64937a74b1 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 35655026161..78269386653 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 00000000000..925156258c9 --- /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 00000000000..b506fee182f --- /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 7bf40fbffe3..348fa157000 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 9efada55492..670ee4e6dfb 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 71a3460ce86..f1ff265339a 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 1444cf99777..ed6703f66cb 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 536631b02d3..0573a3186b8 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 01fbcda7ffb..d3b1394829b 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 f782326295a..9c1a6feeeea 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 00000000000..4f9651a75da --- /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 00000000000..f74d1a26784 --- /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 00000000000..51745f88737 --- /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 963ae6cf42e..70365497bcc 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 8d626c45938..ab35b8b598c 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 1e0db29cac1..a9bf7058eb6 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 df7f1de2923..e3de49a6649 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 00000000000..0ede60986c9 --- /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 e29abe1ee1e..becca46499f 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", () => { +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 first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); - const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); + 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 549ca892eab..9e012f0549d 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 5bbc160752e..0bd7bc6eecc 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 14fc299e9b7..7902b3eace2 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("/");