feat: CMD+F find-in-channel search (#400)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-24 19:15:07 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fa9f3f136d
commit 480d78cb91
17 changed files with 520 additions and 19 deletions
+11 -1
View File
@@ -23,6 +23,10 @@ pub struct SearchParams {
pub q: Option<String>,
/// Maximum number of results to return. Defaults to 20, capped at 100.
pub limit: Option<u32>,
/// 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<String>,
}
/// 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<String> = channel_ids.iter().map(|id| id.to_string()).collect();
+6 -1
View File
@@ -35,10 +35,15 @@ pub async fn get_feed(
pub async fn search_messages(
q: String,
limit: Option<u32>,
channel_id: Option<String>,
state: State<'_, AppState>,
) -> Result<SearchResponse, String> {
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
}
+2
View File
@@ -164,6 +164,8 @@ pub struct SearchQueryParams<'a> {
pub q: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_id: Option<&'a str>,
}
#[derive(Serialize)]
@@ -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<typeof useChannelFind>;
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 (
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{channelFind.isOpen ? (
<ChannelFindBar
matchCount={channelFind.matchCount}
matchIndex={channelFind.activeIndex}
onClose={channelFind.close}
onNext={channelFind.goToNext}
onPrevious={channelFind.goToPrevious}
onQueryChange={channelFind.setQuery}
query={channelFind.query}
/>
) : null}
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={openThreadHeadId}
@@ -226,6 +241,9 @@ export const ChannelPane = React.memo(function ChannelPane({
onReply={onOpenThread}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
searchActiveMessageId={channelFind.activeMatch?.messageId ?? null}
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
/>
<MessageComposer
@@ -38,6 +38,7 @@ import type {
Profile,
RelayEvent,
} from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ChannelPane = React.lazy(async () => {
@@ -237,6 +238,11 @@ export function ChannelScreen({
resolvedMessages,
],
);
const channelFind = useChannelFind({
channelId: activeChannelId,
messages: timelineMessages,
});
const directReplyIdsByParentId = React.useMemo(() => {
const map = new Map<string, string[]>();
@@ -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({
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
<ChannelPane
activeChannel={activeChannel}
channelFind={channelFind}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
@@ -30,6 +30,7 @@ export const MessageRow = React.memo(
onToggleReaction,
onReply,
profiles,
searchQuery,
}: {
activeReplyTargetId?: string | null;
highlighted?: boolean;
@@ -44,6 +45,7 @@ export const MessageRow = React.memo(
) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
profiles?: UserProfileLookup;
searchQuery?: string;
}) {
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
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";
@@ -35,6 +35,12 @@ type MessageTimelineProps = {
emoji: string,
remove: boolean,
) => Promise<void>;
/** 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<string>;
/** 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<string | null>(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<HTMLElement>(
`[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}
@@ -29,6 +29,12 @@ type TimelineMessageListProps = {
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
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<string>;
/** 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(
<MessageRow
key={message.id}
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
@@ -94,6 +106,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
/>,
);
+4 -1
View File
@@ -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,
@@ -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<HTMLInputElement>(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 (
<div
className="flex items-center gap-1.5 border-b border-border/80 bg-background px-3 py-1.5"
data-testid="channel-find-bar"
>
<div className="relative flex min-w-0 flex-1 items-center">
<input
ref={inputRef}
className={cn(
"h-7 w-full rounded-md border border-input bg-transparent px-2 pr-20 text-sm",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
)}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Find in channel"
type="text"
value={query}
/>
{matchLabel ? (
<span className="pointer-events-none absolute right-2 text-xs text-muted-foreground">
{matchLabel}
</span>
) : null}
</div>
<Button
aria-label="Previous match"
className="h-7 w-7"
disabled={matchCount === 0}
onClick={onPrevious}
size="icon"
variant="ghost"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
aria-label="Next match"
className="h-7 w-7"
disabled={matchCount === 0}
onClick={onNext}
size="icon"
variant="ghost"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
aria-label="Close find bar"
className="h-7 w-7"
onClick={onClose}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}
@@ -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<string[]>(() => {
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<string[]>(() => {
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,
};
}
+5 -4
View File
@@ -681,10 +681,11 @@ export async function getHomeFeed(
export async function searchMessages(
input: SearchMessagesInput,
): Promise<SearchMessagesResponse> {
const response = await invokeTauri<RawSearchResponse>(
"search_messages",
input,
);
const response = await invokeTauri<RawSearchResponse>("search_messages", {
q: input.q,
limit: input.limit,
channelId: input.channelId,
});
return {
hits: response.hits.map(fromRawSearchHit),
+1
View File
@@ -209,6 +209,7 @@ export type GetHomeFeedInput = {
export type SearchMessagesInput = {
q: string;
limit?: number;
channelId?: string;
};
export type SearchHit = {
@@ -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",
@@ -0,0 +1,98 @@
/**
* Rehype plugin that highlights text matching a search query by wrapping
* matches in `<mark>` 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<string, unknown>;
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 <code> or <pre> — 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);
};
}
+5
View File
@@ -188,6 +188,11 @@
@apply border-border;
}
mark {
background-color: transparent;
color: inherit;
}
html.dark {
color-scheme: dark;
}
+24 -9
View File
@@ -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<any[]>(() => [rehypeImageGallery], []);
const rehypePlugins = React.useMemo<any[]>(() => {
// 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 = (
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
>
{processedContent}
</ReactMarkdown>
);
return (
<div
className={cn(
@@ -412,13 +432,7 @@ function MarkdownInner({
className,
)}
>
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
>
{processedContent}
</ReactMarkdown>
{markdownNode}
</div>
);
}
@@ -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";