368 lines
8.8 KiB
TypeScript
368 lines
8.8 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 { 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<string>(["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<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 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<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;
|
|
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<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 = "";
|
|
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();
|