mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: CMD+F find-in-channel search (#400)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -188,6 +188,11 @@
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user