From da30ff0d3266813555a48d709b92c20e8d771953 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 11:54:34 -0700 Subject: [PATCH 1/2] feat: add CMD+F find-in-channel search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-tier search: instant client-side substring matching against loaded messages, supplemented by debounced Typesense relay search for stemmed/ fuzzy hits. Find bar with match counter, prev/next navigation, keyboard shortcuts (Enter/Shift+Enter/Escape), auto-scroll to active match, and inline text highlighting via a rehype plugin. New files: - ChannelFindBar.tsx — find bar UI component - useChannelFind.ts — search state, two-tier matching, keyboard shortcut - rehypeSearchHighlight.ts — rehype plugin for highlighting Backend: added channel_id filter to relay search API, Tauri command, and TypeScript types. Co-Authored-By: Claude Opus 4.6 --- crates/sprout-relay/src/api/search.rs | 12 +- desktop/src-tauri/src/commands/messages.rs | 7 +- desktop/src-tauri/src/models.rs | 2 + .../src/features/channels/ui/ChannelPane.tsx | 18 ++ .../features/channels/ui/ChannelScreen.tsx | 8 +- .../src/features/messages/ui/MessageRow.tsx | 6 +- .../features/messages/ui/MessageTimeline.tsx | 35 ++++ .../messages/ui/TimelineMessageList.tsx | 15 +- desktop/src/features/search/hooks.ts | 5 +- .../src/features/search/ui/ChannelFindBar.tsx | 116 +++++++++++++ desktop/src/features/search/useChannelFind.ts | 161 ++++++++++++++++++ desktop/src/shared/api/tauri.ts | 9 +- desktop/src/shared/api/types.ts | 1 + desktop/src/shared/lib/keyboard-shortcuts.ts | 8 + .../src/shared/lib/rehypeSearchHighlight.ts | 98 +++++++++++ desktop/src/shared/ui/markdown.tsx | 33 +++- 16 files changed, 515 insertions(+), 19 deletions(-) create mode 100644 desktop/src/features/search/ui/ChannelFindBar.tsx create mode 100644 desktop/src/features/search/useChannelFind.ts create mode 100644 desktop/src/shared/lib/rehypeSearchHighlight.ts diff --git a/crates/sprout-relay/src/api/search.rs b/crates/sprout-relay/src/api/search.rs index 34d5006c..70de6d29 100644 --- a/crates/sprout-relay/src/api/search.rs +++ b/crates/sprout-relay/src/api/search.rs @@ -23,6 +23,10 @@ pub struct SearchParams { pub q: Option, /// Maximum number of results to return. Defaults to 20, capped at 100. pub limit: Option, + /// Restrict results to a single channel. When present, ANDed with the + /// ACL-based channel filter so the caller can only see results they already + /// have access to. + pub channel_id: Option, } /// Full-text search over messages accessible to the authenticated user. @@ -56,7 +60,13 @@ pub async fn search_handler( if channel_ids.is_empty() && !include_global { return Ok(Json(serde_json::json!({ "hits": [], "found": 0 }))); } - let filter_by = if channel_ids.is_empty() { + let filter_by = if let Some(ref cid) = params.channel_id { + // Scoped to a single channel — verify it's in the accessible set. + if !channel_ids.iter().any(|id| id.to_string() == *cid) { + return Ok(Json(serde_json::json!({ "hits": [], "found": 0 }))); + } + Some(format!("channel_id:={cid}")) + } else if channel_ids.is_empty() { Some("channel_id:=__global__".to_string()) } else if include_global { let ids: Vec = channel_ids.iter().map(|id| id.to_string()).collect(); diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 967bf87c..b0dfdf85 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -35,10 +35,15 @@ pub async fn get_feed( pub async fn search_messages( q: String, limit: Option, + channel_id: Option, state: State<'_, AppState>, ) -> Result { let request = build_authed_request(&state.http_client, Method::GET, "/api/search", &state)? - .query(&SearchQueryParams { q: q.trim(), limit }); + .query(&SearchQueryParams { + q: q.trim(), + limit, + channel_id: channel_id.as_deref(), + }); send_json_request(request).await } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index ced3e086..ba54c915 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -164,6 +164,8 @@ pub struct SearchQueryParams<'a> { pub q: &'a str, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_id: Option<&'a str>, } #[derive(Serialize)] diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7832c8b7..0b9ea969 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,6 +4,8 @@ import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; +import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; +import type { useChannelFind } from "@/features/search/useChannelFind"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -45,6 +47,7 @@ function getInitialThreadPanelWidth(): number { type ChannelPaneProps = { activeChannel: Channel | null; + channelFind: ReturnType; currentPubkey?: string; editTarget?: { author: string; @@ -99,6 +102,7 @@ type ChannelPaneProps = { export const ChannelPane = React.memo(function ChannelPane({ activeChannel, + channelFind, currentPubkey, editTarget = null, fetchOlder, @@ -198,6 +202,17 @@ export const ChannelPane = React.memo(function ChannelPane({ return (
+ {channelFind.isOpen ? ( + + ) : null} { @@ -237,6 +238,11 @@ export function ChannelScreen({ resolvedMessages, ], ); + const channelFind = useChannelFind({ + channelId: activeChannelId, + messages: timelineMessages, + }); + const directReplyIdsByParentId = React.useMemo(() => { const map = new Map(); @@ -341,7 +347,6 @@ export function ChannelScreen({ React.useEffect(() => { resetComposerTargets(activeChannelId); }, [activeChannelId, resetComposerTargets]); - React.useEffect(() => { if (openThreadHeadId && !openThreadHeadMessage) { setOpenThreadHeadId(null); @@ -430,6 +435,7 @@ export function ChannelScreen({ }> Promise; onReply?: (message: TimelineMessage) => void; profiles?: UserProfileLookup; + searchQuery?: string; }) { const [expandedDiffId, setExpandedDiffId] = React.useState( null, @@ -111,6 +113,7 @@ export const MessageRow = React.memo( content={message.body} imetaByUrl={imetaByUrl} mentionNames={mentionNames} + searchQuery={searchQuery} tight /> ); @@ -393,7 +396,8 @@ export const MessageRow = React.memo( prev.highlighted === next.highlighted && prev.activeReplyTargetId === next.activeReplyTargetId && prev.layoutVariant === next.layoutVariant && - prev.profiles === next.profiles, + prev.profiles === next.profiles && + prev.searchQuery === next.searchQuery, ); MessageRow.displayName = "MessageRow"; diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 6a014ee2..0d1e4774 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -35,6 +35,12 @@ type MessageTimelineProps = { emoji: string, remove: boolean, ) => Promise; + /** The message ID of the currently active find-in-channel match. */ + searchActiveMessageId?: string | null; + /** Set of message IDs that match the current find-in-channel query. */ + searchMatchingMessageIds?: Set; + /** The current find-in-channel query string. */ + searchQuery?: string; targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; }; @@ -56,6 +62,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onEdit, onReply, onToggleReaction, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, targetMessageId = null, onTargetReached, }: MessageTimelineProps) { @@ -80,6 +89,29 @@ export const MessageTimeline = React.memo(function MessageTimeline({ targetMessageId, }); + // Scroll to the active search match when it changes. + const prevSearchActiveRef = React.useRef(null); + React.useEffect(() => { + if ( + !searchActiveMessageId || + searchActiveMessageId === prevSearchActiveRef.current + ) { + prevSearchActiveRef.current = searchActiveMessageId; + return; + } + prevSearchActiveRef.current = searchActiveMessageId; + + const container = scrollContainerRef.current; + if (!container) return; + + const el = container.querySelector( + `[data-message-id="${searchActiveMessageId}"]`, + ); + if (el) { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } + }, [searchActiveMessageId]); + useLoadOlderOnScroll({ fetchOlder, hasOlderMessages, @@ -161,6 +193,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onToggleReaction={onToggleReaction} personaLookup={personaLookup} profiles={profiles} + searchActiveMessageId={searchActiveMessageId} + searchMatchingMessageIds={searchMatchingMessageIds} + searchQuery={searchQuery} /> ) : null} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 0dd0a524..647f231b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -29,6 +29,12 @@ type TimelineMessageListProps = { /** Map from lowercase pubkey → persona display name for bot members. */ personaLookup?: Map; profiles?: UserProfileLookup; + /** The message ID of the currently active find-in-channel match. */ + searchActiveMessageId?: string | null; + /** Set of message IDs that match the current find-in-channel query. */ + searchMatchingMessageIds?: Set; + /** The current find-in-channel query string. */ + searchQuery?: string; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -42,6 +48,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onToggleReaction, personaLookup, profiles, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, }: TimelineMessageListProps) { const elements: React.ReactNode[] = []; const entries = React.useMemo( @@ -75,11 +84,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ />, ); } else { + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + elements.push( , ); diff --git a/desktop/src/features/search/hooks.ts b/desktop/src/features/search/hooks.ts index ebbc5ad0..038c6511 100644 --- a/desktop/src/features/search/hooks.ts +++ b/desktop/src/features/search/hooks.ts @@ -5,6 +5,7 @@ import { searchMessages } from "@/shared/api/tauri"; export function useSearchMessagesQuery( query: string, options?: { + channelId?: string; enabled?: boolean; limit?: number; }, @@ -12,13 +13,15 @@ export function useSearchMessagesQuery( const trimmedQuery = query.trim(); const enabled = options?.enabled ?? true; const limit = options?.limit ?? 12; + const channelId = options?.channelId; return useQuery({ - queryKey: ["search-messages", trimmedQuery, limit], + queryKey: ["search-messages", trimmedQuery, limit, channelId ?? null], queryFn: () => searchMessages({ q: trimmedQuery, limit, + channelId, }), enabled: enabled && trimmedQuery.length >= 2, staleTime: 30_000, diff --git a/desktop/src/features/search/ui/ChannelFindBar.tsx b/desktop/src/features/search/ui/ChannelFindBar.tsx new file mode 100644 index 00000000..b9c97dc5 --- /dev/null +++ b/desktop/src/features/search/ui/ChannelFindBar.tsx @@ -0,0 +1,116 @@ +import { ChevronDown, ChevronUp, X } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +type ChannelFindBarProps = { + matchCount: number; + matchIndex: number; + onClose: () => void; + onNext: () => void; + onPrevious: () => void; + onQueryChange: (query: string) => void; + query: string; +}; + +export function ChannelFindBar({ + matchCount, + matchIndex, + onClose, + onNext, + onPrevious, + onQueryChange, + query, +}: ChannelFindBarProps) { + const inputRef = React.useRef(null); + + React.useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (event.shiftKey) { + onPrevious(); + } else { + onNext(); + } + } + }; + + const matchLabel = + query.length >= 2 + ? matchCount > 0 + ? `${matchIndex + 1} of ${matchCount}` + : "No results" + : null; + + return ( +
+
+ onQueryChange(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="Find in channel" + type="text" + value={query} + /> + {matchLabel ? ( + + {matchLabel} + + ) : null} +
+ + + + + + +
+ ); +} diff --git a/desktop/src/features/search/useChannelFind.ts b/desktop/src/features/search/useChannelFind.ts new file mode 100644 index 00000000..c31ea4f6 --- /dev/null +++ b/desktop/src/features/search/useChannelFind.ts @@ -0,0 +1,161 @@ +import * as React from "react"; + +import { useSearchMessagesQuery } from "@/features/search/hooks"; +import type { TimelineMessage } from "@/features/messages/types"; + +const MIN_QUERY_LENGTH = 2; +const DEBOUNCE_MS = 300; + +type UseChannelFindOptions = { + channelId: string | null; + messages: TimelineMessage[]; +}; + +export function useChannelFind({ channelId, messages }: UseChannelFindOptions) { + const [isOpen, setIsOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [debouncedQuery, setDebouncedQuery] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(0); + + const reset = React.useCallback(() => { + setIsOpen(false); + setQuery(""); + setDebouncedQuery(""); + setActiveIndex(0); + }, []); + + // Debounce the query for relay search. + React.useEffect(() => { + const trimmed = query.trim(); + if (trimmed.length < MIN_QUERY_LENGTH) { + setDebouncedQuery(""); + return; + } + + const timeout = window.setTimeout(() => { + setDebouncedQuery(trimmed); + }, DEBOUNCE_MS); + + return () => window.clearTimeout(timeout); + }, [query]); + + // Client-side search: instant matches against loaded messages. + const clientMatchIds = React.useMemo(() => { + const trimmed = query.trim().toLowerCase(); + if (trimmed.length < MIN_QUERY_LENGTH) { + return []; + } + + const found: string[] = []; + for (const message of messages) { + if (message.body.toLowerCase().includes(trimmed)) { + found.push(message.id); + } + } + + return found; + }, [messages, query]); + + // Relay-backed search: full history via Typesense. + const relaySearch = useSearchMessagesQuery(debouncedQuery, { + channelId: channelId ?? undefined, + enabled: isOpen && debouncedQuery.length >= MIN_QUERY_LENGTH, + limit: 100, + }); + + // Merge: start with client-side matches, then supplement with relay hits + // that are loaded in the timeline but were missed by exact substring match + // (e.g. Typesense stemming). Only loaded messages are kept so the match + // count stays accurate relative to what's visible on screen. + const loadedMessageIds = React.useMemo( + () => new Set(messages.map((m) => m.id)), + [messages], + ); + + const matchedIds = React.useMemo(() => { + const merged = [...clientMatchIds]; + const seen = new Set(merged); + + if (relaySearch.data?.hits) { + for (const hit of relaySearch.data.hits) { + if (!seen.has(hit.eventId) && loadedMessageIds.has(hit.eventId)) { + merged.push(hit.eventId); + seen.add(hit.eventId); + } + } + } + + return merged; + }, [clientMatchIds, relaySearch.data?.hits, loadedMessageIds]); + + // Clamp active index when results change. + React.useEffect(() => { + setActiveIndex((current) => { + if (matchedIds.length === 0) return 0; + return current >= matchedIds.length ? 0 : current; + }); + }, [matchedIds.length]); + + const activeMatch = + matchedIds.length > 0 ? { messageId: matchedIds[activeIndex] } : null; + + const matchingMessageIds = React.useMemo(() => { + return new Set(matchedIds); + }, [matchedIds]); + + const close = React.useCallback(() => { + reset(); + }, [reset]); + + const goToNext = React.useCallback(() => { + if (matchedIds.length === 0) return; + setActiveIndex((current) => (current + 1) % matchedIds.length); + }, [matchedIds.length]); + + const goToPrevious = React.useCallback(() => { + if (matchedIds.length === 0) return; + setActiveIndex((current) => + current === 0 ? matchedIds.length - 1 : current - 1, + ); + }, [matchedIds.length]); + + // Register CMD+F keyboard shortcut. + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "f" + ) { + event.preventDefault(); + setIsOpen(true); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + // Close find bar when switching channels. + const prevChannelIdRef = React.useRef(channelId); + React.useEffect(() => { + if (prevChannelIdRef.current !== channelId) { + prevChannelIdRef.current = channelId; + reset(); + } + }, [channelId, reset]); + + return { + activeIndex, + activeMatch, + close, + goToNext, + goToPrevious, + isOpen, + matchCount: matchedIds.length, + matchingMessageIds, + query, + setQuery, + }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 9f12ee9c..41564d5e 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -681,10 +681,11 @@ export async function getHomeFeed( export async function searchMessages( input: SearchMessagesInput, ): Promise { - const response = await invokeTauri( - "search_messages", - input, - ); + const response = await invokeTauri("search_messages", { + q: input.q, + limit: input.limit, + channelId: input.channelId, + }); return { hits: response.hits.map(fromRawSearchHit), diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7459547f..8db839c4 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -209,6 +209,7 @@ export type GetHomeFeedInput = { export type SearchMessagesInput = { q: string; limit?: number; + channelId?: string; }; export type SearchHit = { diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index 1853fbb3..d5cf1db2 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -63,6 +63,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ keysWindows: "Alt+→", category: "Navigation", }, + { + id: "find-in-channel", + label: "Find in channel", + description: "Search messages in current channel", + keys: "⌘F", + keysWindows: "Ctrl+F", + category: "Navigation", + }, { id: "go-home", label: "Home", diff --git a/desktop/src/shared/lib/rehypeSearchHighlight.ts b/desktop/src/shared/lib/rehypeSearchHighlight.ts new file mode 100644 index 00000000..8e4b3e38 --- /dev/null +++ b/desktop/src/shared/lib/rehypeSearchHighlight.ts @@ -0,0 +1,98 @@ +/** + * Rehype plugin that highlights text matching a search query by wrapping + * matches in `` elements during the HAST (HTML AST) phase. + * + * This runs inside the react-markdown pipeline, so it works correctly with + * ReactMarkdown's architecture — no post-render tree walking needed. + */ + +// Minimal HAST types — matches the pattern in rehypeImageGallery.ts. +interface HastText { + type: "text"; + value: string; +} + +interface HastElement { + type: "element"; + tagName: string; + properties: Record; + children: HastNode[]; +} + +type HastNode = HastElement | HastText | { type: string }; + +interface HastRoot { + type: "root"; + children: HastNode[]; +} + +function isElement(node: HastNode): node is HastElement { + return node.type === "element"; +} + +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) { + 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. + result.push({ + type: "element", + tagName: "mark", + properties: { + className: + "rounded-sm bg-primary/20 text-foreground dark:bg-primary/30", + }, + children: [{ type: "text", value: part }], + }); + } else { + result.push({ type: "text", value: part }); + } + } + } else if (isElement(node)) { + // Don't descend into or
 — keep code blocks untouched.
+          if (node.tagName === "code" || node.tagName === "pre") {
+            result.push(node);
+          } else {
+            result.push({
+              ...node,
+              children: walk(node.children),
+            });
+          }
+        } else {
+          result.push(node);
+        }
+      }
+
+      return result;
+    }
+
+    tree.children = walk(tree.children);
+  };
+}
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 6cc3dcdf..cb2520fb 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -10,6 +10,7 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"
 import { cn } from "@/shared/lib/cn";
 import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
 import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
+import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
 import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
 import remarkMentions from "@/shared/lib/remarkMentions";
 
@@ -30,6 +31,7 @@ type MarkdownProps = {
   content: string;
   imetaByUrl?: ImetaLookup;
   mentionNames?: string[];
+  searchQuery?: string;
   tight?: boolean;
 };
 
@@ -354,6 +356,7 @@ function MarkdownInner({
   content,
   imetaByUrl,
   mentionNames,
+  searchQuery,
   tight = false,
 }: MarkdownProps) {
   const variant: MarkdownVariant = tight
@@ -389,7 +392,14 @@ function MarkdownInner({
   );
 
   // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
-  const rehypePlugins = React.useMemo(() => [rehypeImageGallery], []);
+  const rehypePlugins = React.useMemo(() => {
+    // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
+    const plugins: any[] = [rehypeImageGallery];
+    if (searchQuery && searchQuery.trim().length >= 2) {
+      plugins.push([rehypeSearchHighlight, { query: searchQuery }]);
+    }
+    return plugins;
+  }, [searchQuery]);
 
   let processedContent = content;
 
@@ -401,6 +411,16 @@ function MarkdownInner({
     processedContent = `${processedContent}\u200B`;
   }
 
+  const markdownNode = (
+    
+      {processedContent}
+    
+  );
+
   return (
     
- - {processedContent} - + {markdownNode}
); } @@ -432,7 +446,8 @@ export const Markdown = React.memo( prev.tight === next.tight && shallowArrayEqual(prev.mentionNames, next.mentionNames) && shallowArrayEqual(prev.channelNames, next.channelNames) && - prev.imetaByUrl === next.imetaByUrl, + prev.imetaByUrl === next.imetaByUrl && + prev.searchQuery === next.searchQuery, ); Markdown.displayName = "Markdown"; From 9380000a981f83a671a9285b0c54e0b8d1a99758 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 12:04:29 -0700 Subject: [PATCH 2/2] fix: reset native background so accent highlight shows through The browser default yellow background on elements was overriding the Tailwind bg-primary/20 class set by the rehype search highlight plugin. Co-Authored-By: Claude Opus 4.6 --- desktop/src/shared/styles/globals.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 18fc24b1..3af36816 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -188,6 +188,11 @@ @apply border-border; } + mark { + background-color: transparent; + color: inherit; + } + html.dark { color-scheme: dark; }