mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
191 lines
5.2 KiB
TypeScript
191 lines
5.2 KiB
TypeScript
import * as React from "react";
|
|
|
|
import { useSearchMessagesQuery } from "@/features/search/hooks";
|
|
import type { TimelineMessage } from "@/features/messages/types";
|
|
import type { SearchHit } from "@/shared/api/types";
|
|
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
|
|
|
const MIN_QUERY_LENGTH = 2;
|
|
const DEBOUNCE_MS = 300;
|
|
|
|
type UseChannelFindOptions = {
|
|
channelId: string | null;
|
|
messages: TimelineMessage[];
|
|
onSearchHit?: (hit: SearchHit) => void;
|
|
};
|
|
|
|
export function useChannelFind({
|
|
channelId,
|
|
messages,
|
|
onSearchHit,
|
|
}: 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 Postgres FTS.
|
|
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.
|
|
// Relay hits may refer to older messages outside the initial cold window;
|
|
// keep them in the match list and ask the route-target splice path to load
|
|
// the active hit so the DOM-based timeline scroll can land it.
|
|
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)) {
|
|
merged.push(hit.eventId);
|
|
seen.add(hit.eventId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}, [clientMatchIds, relaySearch.data?.hits]);
|
|
|
|
// 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 relayHitById = React.useMemo(() => {
|
|
const hits = new Map<string, SearchHit>();
|
|
for (const hit of relaySearch.data?.hits ?? []) {
|
|
hits.set(hit.eventId, hit);
|
|
}
|
|
return hits;
|
|
}, [relaySearch.data?.hits]);
|
|
|
|
React.useEffect(() => {
|
|
if (!activeMatch) return;
|
|
const hit = relayHitById.get(activeMatch.messageId);
|
|
if (hit) onSearchHit?.(hit);
|
|
}, [activeMatch, onSearchHit, relayHitById]);
|
|
|
|
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 platform-standard find shortcut (⌘F on macOS, Ctrl+F elsewhere).
|
|
React.useEffect(() => {
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (
|
|
hasPrimaryShortcutModifier(event) &&
|
|
!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 React.useMemo(
|
|
() => ({
|
|
activeIndex,
|
|
activeMatch,
|
|
close,
|
|
goToNext,
|
|
goToPrevious,
|
|
isOpen,
|
|
matchCount: matchedIds.length,
|
|
matchingMessageIds,
|
|
query,
|
|
setQuery,
|
|
}),
|
|
[
|
|
activeIndex,
|
|
activeMatch,
|
|
close,
|
|
goToNext,
|
|
goToPrevious,
|
|
isOpen,
|
|
matchedIds.length,
|
|
matchingMessageIds,
|
|
query,
|
|
],
|
|
);
|
|
}
|