Files
OwnCord/Client/tauri-client/src/components/SearchOverlay.ts
T
jevb 39658e919b refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements
Server:
- Unified permission checker (permissions/checker.go) replaces 3 duplicated implementations
- WS handler registry pattern (ws/registry.go) replaces monolithic switch (747→184 lines)
- Split handlers into domain files: handlers_chat.go, handlers_presence.go, handlers_reaction.go
- Shared message type constants (ws/message_types.go) — no more string literals
- Admin API split into helpers.go, types.go, middleware.go (api.go now 61 lines)
- Dev seed script (scripts/seed.go) with -confirm-dev safety flag
- Air hot reload config (.air.toml)
- Fix: DM attachment permission now uses participant check, not role check
- Fix: Typing broadcast now checks ReadMessages permission for non-DM channels

Client:
- Extract preferences to @lib/preferences.ts (fixes lib→component dependency)
- Extract roles to dedicated roles.store.ts (was mixed into channels store)
- Decompose SidebarArea (921→598 lines) into 4 sub-components
- Shared modal factory (lib/modalFactory.ts) with tests
- Global showToast() helper (lib/toast.ts) — 18 call sites migrated
- Protocol type constants (lib/protocolTypes.ts) synced with server
- Remove 38 unnecessary type casts across 17 files
- Component test harness (tests/helpers/test-harness.ts) with 8 tests
- Fix: DM section "View All" respects collapsed state
- Fix: Modal onClose fires on external signal abort
- Fix: savePref wrapped in try/catch for quota exceeded
- Fix: loadPref null guard added

Triple-reviewed: Claude code-review agent + OpenAI Codex CLI + GitHub Copilot
2026-03-29 12:19:08 +02:00

230 lines
6.8 KiB
TypeScript

/**
* SearchOverlay — full-text message search overlay with debounced input,
* result list rendering, and keyboard navigation.
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { SearchResultItem } from "@lib/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SearchOverlayOptions {
readonly onSearch: (query: string, channelId?: number, signal?: AbortSignal) => Promise<readonly SearchResultItem[]>;
readonly onSelectResult: (result: SearchResultItem) => void;
readonly onClose: () => void;
readonly currentChannelId?: number;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEBOUNCE_MS = 300;
const MIN_QUERY_LEN = 2;
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createSearchOverlay(options: SearchOverlayOptions): MountableComponent {
const ac = new AbortController();
const signal = ac.signal;
let root: HTMLDivElement | null = null;
let resultsDiv: HTMLDivElement;
let input: HTMLInputElement;
let statusEl: HTMLDivElement;
let activeIndex = 0;
let results: readonly SearchResultItem[] = [];
let debounceTimer: number | null = null;
let searchAbort: AbortController | null = null;
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
+ " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
} catch {
return ts;
}
}
function renderResults(): void {
clearChildren(resultsDiv);
activeIndex = Math.min(activeIndex, Math.max(0, results.length - 1));
for (let i = 0; i < results.length; i++) {
const r = results[i]!;
const isActive = i === activeIndex;
const item = createElement("div", {
class: isActive
? "search-result-item search-result-item--active"
: "search-result-item",
role: "option",
"aria-selected": isActive ? "true" : "false",
"data-testid": `search-result-${i}`,
});
const header = createElement("div", { class: "search-result-header" });
const channel = createElement("span", { class: "search-result-channel" });
setText(channel, `#${r.channel_name}`);
const author = createElement("span", { class: "search-result-author" });
setText(author, r.user.username);
const time = createElement("span", { class: "search-result-time" });
setText(time, formatTimestamp(r.timestamp));
appendChildren(header, channel, author, time);
const content = createElement("div", { class: "search-result-content" });
setText(content, r.content.length > 200 ? r.content.slice(0, 200) + "..." : r.content);
appendChildren(item, header, content);
item.addEventListener("click", () => {
options.onSelectResult(r);
options.onClose();
}, { signal });
resultsDiv.appendChild(item);
}
}
function setStatus(text: string): void {
setText(statusEl, text);
statusEl.style.display = text === "" ? "none" : "block";
}
function doSearch(): void {
const query = input.value.trim();
if (query.length < MIN_QUERY_LEN) {
results = [];
renderResults();
setStatus(query.length > 0 ? `Type at least ${MIN_QUERY_LEN} characters` : "");
return;
}
// Cancel any in-flight search
if (searchAbort !== null) {
searchAbort.abort();
}
searchAbort = new AbortController();
setStatus("Searching...");
options.onSearch(query, options.currentChannelId, searchAbort.signal)
.then((items) => {
results = items;
activeIndex = 0;
renderResults();
setStatus(items.length === 0 ? "No results found" : "");
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
setStatus("Search failed");
});
}
function handleInput(): void {
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(doSearch, DEBOUNCE_MS);
}
function handleKeydown(e: KeyboardEvent): void {
if (e.key === "Escape") {
e.preventDefault();
options.onClose();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (results.length > 0) {
activeIndex = (activeIndex + 1) % results.length;
renderResults();
}
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (results.length > 0) {
activeIndex = (activeIndex - 1 + results.length) % results.length;
renderResults();
}
return;
}
if (e.key === "Enter") {
e.preventDefault();
const selected = results[activeIndex];
if (selected !== undefined) {
options.onSelectResult(selected);
options.onClose();
}
}
}
function handleBackdropClick(e: MouseEvent): void {
if (e.target === root) {
options.onClose();
}
}
function mount(container: Element): void {
root = createElement("div", {
class: "search-overlay open",
"data-testid": "search-overlay",
});
const box = createElement("div", { class: "search-overlay-box" });
input = createElement("input", {
class: "search-overlay-input",
type: "text",
placeholder: "Search messages...",
"aria-label": "Search messages",
"data-testid": "search-overlay-input",
});
statusEl = createElement("div", {
class: "search-overlay-status",
style: "display:none",
});
resultsDiv = createElement("div", {
class: "search-overlay-results",
role: "listbox",
"data-testid": "search-overlay-results",
});
appendChildren(box, input, statusEl, resultsDiv);
root.appendChild(box);
container.appendChild(root);
input.addEventListener("input", handleInput, { signal });
input.addEventListener("keydown", handleKeydown, { signal });
root.addEventListener("click", handleBackdropClick, { signal });
requestAnimationFrame(() => input.focus());
}
function destroy(): void {
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
debounceTimer = null;
}
if (searchAbort !== null) {
searchAbort.abort();
searchAbort = null;
}
ac.abort();
root?.remove();
root = null;
}
return { mount, destroy };
}