import { discoverPeers, listChats, searchMessages, } from "#lib/api/endpoints.js"; import type { Chat, DiscoverItem, SearchChatKind, SearchHit, SearchQuery, } from "#lib/api/types.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { goto } from "$app/navigation"; import { page } from "$app/state"; export type SearchFlag = "deleted" | "edited" | "media"; export interface SearchFilters { deleted: boolean; edited: boolean; from: number | null; kind: SearchChatKind | null; media: boolean; since: string | null; until: string | null; } const DEBOUNCE_MS = 250; const REMOTE_DELAY_MS = 450; const CHAT_LIMIT = 30; const PAGE_SIZE = 30; const KINDS = new Set(["private", "group", "channel"]); const DAY = /^\d{4}-\d{2}-\d{2}$/; const PARAMS = [ "q", "deleted", "edited", "media", "kind", "from", "since", "until", ] as const; type Param = (typeof PARAMS)[number]; function day(value: string | null): string | null { return value && DAY.test(value) ? value : null; } function readFilters( params: Pick ): SearchFilters { const kind = params.get("kind"); const from = Number(params.get("from")); return { deleted: params.has("deleted"), edited: params.has("edited"), from: Number.isSafeInteger(from) && from !== 0 ? from : null, kind: kind && KINDS.has(kind) ? (kind as SearchChatKind) : null, media: params.has("media"), since: day(params.get("since")), until: day(params.get("until")), }; } function isFiltered(filters: SearchFilters): boolean { return Object.values(filters).some(Boolean); } function toRequest(query: string, filters: SearchFilters): SearchQuery { return { date_from: filters.since ? new Date(`${filters.since}T00:00:00`).toISOString() : undefined, date_to: filters.until ? new Date(`${filters.until}T23:59:59.999`).toISOString() : undefined, deleted: filters.deleted || undefined, edited: filters.edited || undefined, has_media: filters.media || undefined, kind: filters.kind ?? undefined, query: query || undefined, sender_id: filters.from ?? undefined, }; } export function createHitPager() { let hits = $state([]); let terms = $state([]); let cursor = $state(null); let loading = $state(false); let appending = $state(false); let failed = $state(false); let base: SearchQuery | null = null; let seq = 0; async function load(current: number, request: SearchQuery) { failed = false; try { const result = await searchMessages({ ...request, limit: PAGE_SIZE }); if (current !== seq) { return; } hits = request.cursor ? [...hits, ...result.hits] : result.hits; ({ terms } = result); cursor = result.next_cursor; } catch { if (current === seq) { failed = true; } } finally { if (current === seq) { loading = false; appending = false; } } } function run(request: SearchQuery | null) { seq += 1; base = request; cursor = null; failed = false; appending = false; loading = request !== null; if (request === null) { hits = []; terms = []; return; } load(seq, request).catch(() => undefined); } return { get appending() { return appending; }, get failed() { return failed; }, get hasMore() { return cursor !== null; }, get hits() { return hits; }, get loading() { return loading; }, more() { if (base === null || cursor === null || loading || appending) { return; } appending = true; load(seq, { ...base, cursor }).catch(() => undefined); }, retry() { run(base); }, run, get terms() { return terms; }, }; } function createSearch() { let open = $state(false); let draft = $state(""); let pending = $state(false); let chatHits = $state([]); let peerResults = $state([]); let timer: ReturnType | null = null; let remoteTimer: ReturnType | null = null; let lastRequest = ""; let lastText = ""; let seq = 0; const messages = createHitPager(); const params = $derived(page.url.searchParams); const urlQuery = $derived(params.get("q") ?? ""); const filters = $derived(readFilters(params)); const filtered = $derived(isFiltered(filters)); const text = $derived(pending ? draft : urlQuery); const active = $derived(open || text !== "" || filtered); const request = $derived.by(() => { const query = urlQuery.trim(); if (!(query || filtered)) { return ""; } return JSON.stringify([accounts.selectedId, toRequest(query, filters)]); }); const peerHits = $derived.by(() => { const shown = new Set(chatHits.map((chat) => chat.chat_id)); return peerResults.filter((item) => !shown.has(item.chat_id)); }); function write(update: Partial>) { const url = new URL(page.url.href); url.searchParams.delete("message"); for (const [key, value] of Object.entries(update)) { if (value) { url.searchParams.set(key, value); } else { url.searchParams.delete(key); } } if (url.search === page.url.search) { return Promise.resolve(); } return goto(url, { replace: true, reset: false }); } async function runChats(value: string, current: number) { try { const found = await listChats({ limit: CHAT_LIMIT, search: value }); if (current === seq) { chatHits = found; } } catch { if (current === seq) { chatHits = []; } } } async function runPeers(value: string, current: number, remote: boolean) { try { const items = await discoverPeers(value, remote); if (current === seq) { peerResults = items; } } catch { if (current === seq && !remote) { peerResults = []; } } } function runText(value: string) { seq += 1; const current = seq; if (remoteTimer !== null) { clearTimeout(remoteTimer); remoteTimer = null; } if (!value) { chatHits = []; peerResults = []; return; } runChats(value, current).catch(() => undefined); runPeers(value, current, false).catch(() => undefined); remoteTimer = setTimeout(() => { runPeers(value, current, true).catch(() => undefined); }, REMOTE_DELAY_MS); } function stopTyping() { if (timer !== null) { clearTimeout(timer); timer = null; } pending = false; } function reset() { open = false; draft = ""; stopTyping(); lastRequest = ""; lastText = ""; messages.run(null); runText(""); } return { get active() { return active; }, get chatHits() { return chatHits; }, close() { reset(); const update = Object.fromEntries(PARAMS.map((key) => [key, null])); write(update).catch(() => undefined); }, get filtered() { return filtered; }, get filters() { return filters; }, hitHref(chatId: number, messageId: number): string { const next = new URLSearchParams({ message: String(messageId) }); for (const key of PARAMS) { const value = params.get(key); if (value) { next.set(key, value); } } return `/app/${chatId}?${next}`; }, messages, open() { open = true; }, get peerHits() { return peerHits; }, get request() { return request; }, reset, setFilter(key: "from" | "kind" | "since" | "until", value: string | null) { write({ [key]: value }).catch(() => undefined); }, setPeriod(since: string | null, until: string | null) { write({ since, until }).catch(() => undefined); }, setQuery(value: string) { draft = value; pending = true; if (timer !== null) { clearTimeout(timer); } timer = setTimeout(() => { timer = null; write({ q: draft.trim() ? draft : null }) .catch(() => undefined) .finally(() => { if (timer === null) { pending = false; } }); }, DEBOUNCE_MS); }, get showResults() { return active && (text.trim() !== "" || filtered); }, sync(key: string) { if (key !== lastRequest) { lastRequest = key; messages.run(key ? (JSON.parse(key)[1] as SearchQuery) : null); } const value = urlQuery.trim(); if (value !== lastText) { lastText = value; runText(value); } }, get text() { return text; }, toggle(flag: SearchFlag) { write({ [flag]: filters[flag] ? null : "1" }).catch(() => undefined); }, get typing() { return pending; }, }; } export const search = createSearch();