Files
beavergram/frontend/src/lib/stores/search.svelte.ts
T

415 lines
10 KiB
TypeScript

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 { MARK_PARAM } from "#lib/stores/feed.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;
export const EVERYWHERE_MIN = 3;
const KINDS = new Set<string>(["private", "group", "channel"]);
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const PARAMS = [
"q",
"deleted",
"edited",
"media",
"kind",
"from",
"since",
"until",
"everywhere",
] 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<URLSearchParams, "get" | "has">
): 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<SearchHit[]>([]);
let terms = $state<string[]>([]);
let cursor = $state<string | null>(null);
let scannedTo = $state<string | null>(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;
scannedTo = result.scanned_to;
} 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;
scannedTo = 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 scannedTo() {
return scannedTo;
},
get terms() {
return terms;
},
};
}
export type HitPager = ReturnType<typeof createHitPager>;
export function hitKey(hit: SearchHit): string {
return `${hit.chat_id}:${hit.message_id}`;
}
function createSearch() {
let open = $state(false);
let draft = $state("");
let pending = $state(false);
let chatHits = $state<Chat[]>([]);
let peerResults = $state<DiscoverItem[]>([]);
let timer: ReturnType<typeof setTimeout> | null = null;
let remoteTimer: ReturnType<typeof setTimeout> | null = null;
let lastRequest = "";
let lastText = "";
let seq = 0;
let lastEverywhere = "";
const messages = createHitPager();
const everywhere = 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 everywhereRequest = $derived.by(() => {
const query = urlQuery.trim();
if (!params.has("everywhere") || query.length < EVERYWHERE_MIN) {
return "";
}
const deep = { ...toRequest(query, filters), everywhere: true };
return JSON.stringify([accounts.selectedId, deep]);
});
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<Record<Param, string | null>>) {
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 = "";
lastEverywhere = "";
messages.run(null);
everywhere.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);
},
everywhere,
get everywhereRequest() {
return everywhereRequest;
},
get filtered() {
return filtered;
},
get filters() {
return filters;
},
hitHref(chatId: number, messageId: number, marks: string[]): string {
const next = new URLSearchParams({ message: String(messageId) });
for (const mark of marks) {
next.append(MARK_PARAM, mark);
}
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,
searchEverywhere() {
write({ everywhere: "1" }).catch(() => undefined);
},
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;
const same = draft.trim() === urlQuery.trim();
write({
everywhere: same ? params.get("everywhere") : null,
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);
}
},
syncEverywhere(key: string) {
if (key !== lastEverywhere) {
lastEverywhere = key;
everywhere.run(key ? (JSON.parse(key)[1] as SearchQuery) : null);
}
},
get text() {
return text;
},
toggle(flag: SearchFlag) {
write({ [flag]: filters[flag] ? null : "1" }).catch(() => undefined);
},
get typing() {
return pending;
},
};
}
export const search = createSearch();