feat(frontend): search results show deleted, edited and earlier version marks with highlighted words, filter chips live in the url, results load on scroll
This commit is contained in:
1 parent
d45c96c259
commit
9a6bd8a714
15 files changed
+1430
-228
No files matched your search
@@ -3,44 +3,214 @@ import {
|
||||
listChats,
|
||||
searchMessages,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type { Chat, DiscoverItem, SearchHit } from "#lib/api/types.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_DEBOUNCE_MS = 700;
|
||||
const MIN_LENGTH = 1;
|
||||
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;
|
||||
|
||||
function createSearch() {
|
||||
let active = $state(false);
|
||||
let query = $state("");
|
||||
let messageHits = $state<SearchHit[]>([]);
|
||||
let chatHits = $state<Chat[]>([]);
|
||||
let peerResults = $state<DiscoverItem[]>([]);
|
||||
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 timers: ReturnType<typeof setTimeout>[] = [];
|
||||
let appending = $state(false);
|
||||
let failed = $state(false);
|
||||
let base: SearchQuery | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const trimmed = $derived(query.trim());
|
||||
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));
|
||||
});
|
||||
|
||||
async function runMessages(value: string, current: number) {
|
||||
try {
|
||||
const hits = await searchMessages(value);
|
||||
if (current === seq) {
|
||||
messageHits = hits;
|
||||
}
|
||||
} catch {
|
||||
if (current === seq) {
|
||||
messageHits = [];
|
||||
}
|
||||
} finally {
|
||||
if (current === seq) {
|
||||
loading = false;
|
||||
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) {
|
||||
@@ -69,36 +239,41 @@ function createSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimers();
|
||||
const value = trimmed;
|
||||
function runText(value: string) {
|
||||
seq += 1;
|
||||
const current = seq;
|
||||
if (value.length < MIN_LENGTH) {
|
||||
messageHits = [];
|
||||
if (remoteTimer !== null) {
|
||||
clearTimeout(remoteTimer);
|
||||
remoteTimer = null;
|
||||
}
|
||||
if (!value) {
|
||||
chatHits = [];
|
||||
peerResults = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
runMessages(value, current).catch(() => undefined);
|
||||
runChats(value, current).catch(() => undefined);
|
||||
runPeers(value, current, false).catch(() => undefined);
|
||||
}, DEBOUNCE_MS),
|
||||
setTimeout(() => {
|
||||
runPeers(value, current, true).catch(() => undefined);
|
||||
}, REMOTE_DEBOUNCE_MS)
|
||||
);
|
||||
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 {
|
||||
@@ -109,36 +284,82 @@ function createSearch() {
|
||||
return chatHits;
|
||||
},
|
||||
close() {
|
||||
active = false;
|
||||
query = "";
|
||||
messageHits = [];
|
||||
chatHits = [];
|
||||
peerResults = [];
|
||||
loading = false;
|
||||
seq += 1;
|
||||
clearTimers();
|
||||
reset();
|
||||
const update = Object.fromEntries(PARAMS.map((key) => [key, null]));
|
||||
write(update).catch(() => undefined);
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
get filtered() {
|
||||
return filtered;
|
||||
},
|
||||
get messageHits() {
|
||||
return messageHits;
|
||||
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() {
|
||||
active = true;
|
||||
open = true;
|
||||
},
|
||||
get peerHits() {
|
||||
return peerHits;
|
||||
},
|
||||
get query() {
|
||||
return query;
|
||||
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) {
|
||||
query = value;
|
||||
schedule();
|
||||
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 trimmed() {
|
||||
return trimmed;
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user