diff --git a/backend/src/utils/search/found.py b/backend/src/utils/search/found.py new file mode 100644 index 0000000..82765a7 --- /dev/null +++ b/backend/src/utils/search/found.py @@ -0,0 +1,65 @@ +import re +from collections.abc import Iterator +from typing import Any + +from utils.search.models import Found, FoundWhere + +HIDDEN_KEYS = ("chat", "from_user", "sender_chat") +SKIPPED_KEYS = ("_", "file_id", "file_unique_id") +ENUM = re.compile(r"^[A-Z][A-Za-z]+\.[A-Z_]+$") + +_HEADS: dict[str, FoundWhere] = { + "text": "text", + "caption": "text", + "web_page": "preview", +} +_LEAVES: dict[str, FoundWhere] = {"url": "link", "file_name": "file"} + +type Path = tuple[str | int, ...] + + +def _scalars(node: object, path: Path = ()) -> Iterator[tuple[Path, str, object]]: + if isinstance(node, dict): + items = ((k, v) for k, v in node.items() if k not in SKIPPED_KEYS) + elif isinstance(node, list): + items = enumerate(node) + else: + return + for key, value in items: + if isinstance(value, dict | list): + yield from _scalars(value, (*path, key)) + elif isinstance(value, str | int | float) and not isinstance(value, bool): + yield (*path, key), str(value), node + + +def _where(path: Path) -> FoundWhere: + head, leaf = str(path[0]), str(path[-1]) + if head == "reply_markup": + return "button" + if leaf in _LEAVES: + return _LEAVES[leaf] + if head.startswith("forward"): + return "forward" + if head.startswith("reply_to"): + return "reply" + return _HEADS.get(head, "data") + + +def find(raw: dict[str, Any], needle: str) -> Found | None: + folded = needle.casefold() + fallback = None + for path, value, owner in _scalars(raw): + if folded not in value.casefold() or ENUM.match(value): + continue + where = _where(path) + button = owner.get("text") if isinstance(owner, dict) else None + found = Found( + where=where, + path=".".join(map(str, path)), + value=value, + button=button if where == "button" else None, + ) + if where != "text": + return found + fallback = fallback or found + return fallback diff --git a/backend/src/utils/search/models.py b/backend/src/utils/search/models.py index bf3c9c9..e913073 100644 --- a/backend/src/utils/search/models.py +++ b/backend/src/utils/search/models.py @@ -10,7 +10,10 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC) _MICROSECOND = timedelta(microseconds=1) ChatKind = Literal["private", "group", "channel"] -HitSource = Literal["text", "stt", "version"] +HitSource = Literal["text", "stt", "version", "data"] +FoundWhere = Literal[ + "text", "button", "link", "forward", "reply", "file", "preview", "data" +] def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str: @@ -34,6 +37,7 @@ class SearchFilters(BaseModel): date_from: datetime | None = None date_to: datetime | None = None regex: str | None = None + everywhere: bool = False cursor: str | None = None limit: int = DEFAULT_LIMIT @@ -45,6 +49,13 @@ class SearchFilters(BaseModel): return value +class Found(BaseModel): + where: FoundWhere + path: str + value: str + button: str | None = None + + class SearchHit(BaseModel): chat_id: int message_id: int @@ -56,12 +67,14 @@ class SearchHit(BaseModel): edited_at: datetime | None deleted_at: datetime | None source: HitSource - extracted_text: str | None - version_text: str | None - version_at: datetime | None + extracted_text: str | None = None + version_text: str | None = None + version_at: datetime | None = None + found: Found | None = None class SearchPage(BaseModel): hits: list[SearchHit] next_cursor: str | None terms: list[str] + scanned_to: datetime | None = None diff --git a/backend/src/utils/search/repository.py b/backend/src/utils/search/repository.py index e5bb34f..a168874 100644 --- a/backend/src/utils/search/repository.py +++ b/backend/src/utils/search/repository.py @@ -1,5 +1,12 @@ +import json +import re +from datetime import UTC, datetime, timedelta +from time import monotonic + import asyncpg +from utils.read.message_view import load_raw +from utils.search.found import ENUM, HIDDEN_KEYS, SKIPPED_KEYS, find from utils.search.models import ( MAX_LIMIT, ChatKind, @@ -10,6 +17,12 @@ from utils.search.models import ( encode_cursor, ) +MIN_NEEDLE = 3 +_WINDOW = timedelta(days=7) +_BUDGET_SECONDS = 2.5 +_BOTTOM = -(2**63) +_LIKE_SPECIAL = re.compile(r"([\\%_])") + _PARSE = """ SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text, '''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery @@ -76,6 +89,24 @@ _WITH_MEDIA = ( _SOURCES = ("text", "stt", "version") +_OLDEST = "SELECT min(date) FROM message_search WHERE account_id = $1" + +_VALUE_MATCH = ( + 'strict $.** ? (@.type() == "object").keyvalue() ? (' + + "".join(f'@.key != "{key}" && ' for key in SKIPPED_KEYS) + + '(@.value.type() == "string" || @.value.type() == "number") && ' + f"!(@.value.string() like_regex {json.dumps(ENUM.pattern)}) && " + '@.value.string() like_regex {needle} flag "iq")' +) + +_EVERYWHERE = ( + f"SELECT {_COLS}, s.raw - {{hidden}}::text[] AS raw " # noqa: S608 + "FROM messages s WHERE {where} " + "AND CASE WHEN s.raw::text ILIKE {like} " + "THEN jsonb_path_exists(s.raw - {hidden}::text[], {path}::jsonpath) " + f"ELSE false END ORDER BY {_KEY_ORDER} LIMIT {{limit}}" +) + class _Binder: def __init__(self) -> None: @@ -130,9 +161,72 @@ def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str: async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage: async with pool.acquire() as conn, conn.transaction(): await conn.execute("SET LOCAL jit = off") + if filters.everywhere: + return await _everywhere(conn, filters) return await _search(conn, filters) +async def _channels(conn: asyncpg.Connection, filters: SearchFilters) -> list[int]: + if filters.kind in {"group", "channel"}: + return await conn.fetchval(_CHANNELS, filters.account_id) + return [] + + +def _data_hit(row: asyncpg.Record, needle: str) -> SearchHit: + data = dict(row) + raw = load_raw(data.pop("raw")) + return SearchHit(**data, source="data", found=find(raw, needle)) + + +async def _everywhere(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage: + needle = (filters.query or "").strip() + floor = filters.date_from or await conn.fetchval(_OLDEST, filters.account_id) + if len(needle) < MIN_NEEDLE or floor is None: + return SearchPage(hits=[], next_cursor=None, terms=[]) + channels = await _channels(conn, filters) + quoted = json.dumps(needle, ensure_ascii=False) + like = _LIKE_SPECIAL.sub(r"\\\1", quoted[1:-1]) + path = _VALUE_MATCH.format(needle=quoted) + size = min(filters.limit, MAX_LIMIT) + cursor = filters.cursor + top = decode_cursor(cursor)[0] if cursor else None + hits: list[SearchHit] = [] + started = monotonic() + while True: + lower = max((top or filters.date_to or datetime.now(UTC)) - _WINDOW, floor) + bind = _Binder() + conds = _conditions( + filters.model_copy(update={"cursor": cursor}), channels, bind + ) + conds.append(f"s.date >= {bind(lower)}") + if top is not None: + conds.append(f"s.date <= {bind(top)}") + limit = size - len(hits) + sql = _EVERYWHERE.format( + where=" AND ".join(conds), + hidden=bind(list(HIDDEN_KEYS)), + like=bind(f"%{like}%"), + path=bind(path), + limit=bind(limit), + ) + account = bind(filters.account_id) + rows = await conn.fetch( + _WITH_MEDIA.format(hits=sql, account=account), *bind.params + ) + hits += [_data_hit(row, needle) for row in rows] + if len(rows) >= limit: + last = rows[-1] + top = last["date"] + cursor = encode_cursor(top, last["chat_id"], last["message_id"]) + elif lower > floor: + top = lower + cursor = encode_cursor(lower, _BOTTOM, _BOTTOM) + else: + return SearchPage(hits=hits, next_cursor=None, terms=[], scanned_to=floor) + if len(hits) >= size or monotonic() - started > _BUDGET_SECONDS: + return SearchPage(hits=hits, next_cursor=cursor, terms=[], scanned_to=top) + + async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage: query = (filters.query or "").strip() terms: list[str] = [] @@ -144,9 +238,7 @@ async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPag return SearchPage(hits=[], next_cursor=None, terms=terms) tsquery = parsed["tsquery"] - channels: list[int] = [] - if filters.kind in {"group", "channel"}: - channels = await conn.fetchval(_CHANNELS, filters.account_id) + channels = await _channels(conn, filters) bind = _Binder() where = " AND ".join(_conditions(filters, channels, bind)) size = min(filters.limit, MAX_LIMIT) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 05a89b0..771a434 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -7,7 +7,24 @@ export type PolicyScopeType = | "default_channel" | "folder" | "chat"; -export type SearchSource = "text" | "stt" | "version"; +export type SearchSource = "text" | "stt" | "version" | "data"; + +export type FoundWhere = + | "text" + | "button" + | "link" + | "forward" + | "reply" + | "file" + | "preview" + | "data"; + +export interface SearchFound { + button: string | null; + path: string; + value: string; + where: FoundWhere; +} export type SearchChatKind = "private" | "group" | "channel"; export type JobStatus = @@ -289,6 +306,7 @@ export interface SearchHit { deleted_at: string | null; edited_at: string | null; extracted_text: string | null; + found: SearchFound | null; has_media: boolean; media_kind: string | null; message_id: number; @@ -302,6 +320,7 @@ export interface SearchHit { export interface SearchPage { hits: SearchHit[]; next_cursor: string | null; + scanned_to: string | null; terms: string[]; } @@ -312,6 +331,7 @@ export interface SearchQuery { date_to?: string; deleted?: boolean; edited?: boolean; + everywhere?: boolean; has_media?: boolean; kind?: SearchChatKind; limit?: number; diff --git a/frontend/src/lib/components/EntityText.svelte b/frontend/src/lib/components/EntityText.svelte index d593148..1c88acd 100644 --- a/frontend/src/lib/components/EntityText.svelte +++ b/frontend/src/lib/components/EntityText.svelte @@ -8,14 +8,16 @@ jumboEmojiCount, linkHref, } from "#lib/format/entities.js"; + import { markText } from "#lib/format/search.js"; interface Props { entities: EntityView[]; + marks?: string[]; own?: boolean; text: string; } - let { text, entities, own = false }: Props = $props(); + let { text, entities, marks = [], own = false }: Props = $props(); const nodes = $derived(buildEntityTree(text, entities)); const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0); @@ -32,13 +34,29 @@ )} {#each items as node, i (i)} {#if node.kind === "text"} - {node.text} + {@render plain(node.text)} {:else} {@render entity(node)} {/if} {/each} {/snippet} +{#snippet plain( + value: string +)} + {#if marks.length === 0} + {value} + {:else} + {#each markText(value, marks) as segment, i (i)} + {#if segment.hit} + {segment.text} + {:else} + {segment.text} + {/if} + {/each} + {/if} +{/snippet} + {#snippet entity( node: EntityNode )} @@ -186,6 +204,35 @@ background-color: var(--color-primary-tint); } + .found { + padding: 0; + border-radius: 0.25rem; + color: inherit; + background-color: transparent; + animation: found-flash 3s ease; + + --found-color: color-mix(in srgb, var(--color-primary) 40%, transparent); + + &.own { + --found-color: color-mix( + in srgb, + var(--color-own-links) 35%, + transparent + ); + } + } + + @keyframes found-flash { + 0%, + 70% { + background-color: var(--found-color); + } + + 100% { + background-color: transparent; + } + } + .spoiler { cursor: pointer; diff --git a/frontend/src/lib/components/InlineButtons.svelte b/frontend/src/lib/components/InlineButtons.svelte index 2c6d851..3d3f7cb 100644 --- a/frontend/src/lib/components/InlineButtons.svelte +++ b/frontend/src/lib/components/InlineButtons.svelte @@ -1,13 +1,15 @@ + +{#if !started} + {#if ready && needle.length >= EVERYWHERE_MIN} + + {/if} +{:else} + {#if hits.length > 0} +
{t("search.everywhereSection")}
+ {#each hits as hit (hitKey(hit))} + onopen?.(hit)} + terms={[needle]} + /> + {/each} + {/if} + {#if busy} +
+ + + {pager.scannedTo + ? t("search.everywhereScanned", { date: formatDate(pager.scannedTo) }) + : t("search.everywhereScanning")} + +
+ {:else if pager.failed} +
+ {t("search.failed")} + +
+ {:else if pager.hasMore} +
pager.more()}>
+ {:else if hits.length === 0} +
{t("search.everywhereNothing")}
+ {/if} +{/if} + + diff --git a/frontend/src/lib/components/search/SearchMessageItem.svelte b/frontend/src/lib/components/search/SearchMessageItem.svelte index 0a4dc03..3256123 100644 --- a/frontend/src/lib/components/search/SearchMessageItem.svelte +++ b/frontend/src/lib/components/search/SearchMessageItem.svelte @@ -1,12 +1,12 @@ @@ -126,6 +151,21 @@ {/each} + {#if found} +
+ + {foundLabel}: + {#each highlightMarks(found.value, terms) as segment, index (index)} + {#if segment.hit} + {segment.text} + {:else} + {segment.text} + {/if} + {/each} +
+ {/if} {#if hit.source === "version"}
(null); $effect(() => { @@ -25,12 +26,22 @@ }); }); + $effect(() => { + const key = search.everywhereRequest; + untrack(() => search.syncEverywhere(key)); + }); + const activeChatId = $derived( page.params.chatId ? Number(page.params.chatId) : null ); const showChats = $derived(!search.filtered && search.chatHits.length > 0); const showPeers = $derived(!search.filtered && search.peerHits.length > 0); const hasMessages = $derived(messages.hits.length > 0); + const needle = $derived(search.text.trim()); + const searchedAll = $derived( + !(search.typing || messages.loading || messages.failed || messages.hasMore) + ); + const deep = $derived(search.everywhereRequest !== ""); const empty = $derived( !( search.typing || @@ -38,7 +49,8 @@ messages.failed || showChats || showPeers || - hasMessages + hasMessages || + deep ) ); @@ -74,8 +86,9 @@ {#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)} ui.requestJump(hit.chat_id, hit.message_id)} + href={search.hitHref(hit.chat_id, hit.message_id, messages.terms)} + onclick={() => + ui.requestJump(hit.chat_id, hit.message_id, messages.terms)} terms={messages.terms} /> {/each} @@ -98,6 +111,17 @@
{/if} + search.hitHref(hit.chat_id, hit.message_id, [needle])} + {needle} + onopen={(hit) => ui.requestJump(hit.chat_id, hit.message_id, [needle])} + onstart={() => search.searchEverywhere()} + pager={everywhere} + ready={searchedAll} + shown={messages.hits} + started={deep} + /> + {#if empty} {/if} diff --git a/frontend/src/lib/format/search.ts b/frontend/src/lib/format/search.ts index 551c440..6cd1016 100644 --- a/frontend/src/lib/format/search.ts +++ b/frontend/src/lib/format/search.ts @@ -1,8 +1,12 @@ +import type { InlineButton } from "#lib/api/types.js"; + export interface Segment { hit: boolean; text: string; } +type Range = [number, number]; + const WORD = /[\p{L}\p{N}_]+/gu; const SPACE = /\s/; const PREFIX_MIN = 3; @@ -20,39 +24,92 @@ function matches(word: string, terms: string[]): boolean { ); } -function windowed(text: string, terms: string[]): string { - for (const match of text.matchAll(WORD)) { - if (!matches(match[0], terms)) { - continue; - } - if (match.index <= REACH) { - return text; - } - let start = match.index - LEAD; - while (start > 0 && !SPACE.test(text[start - 1])) { - start -= 1; - } - return `…${text.slice(start)}`; - } - return text; +function wordRanges(text: string, terms: string[]): Range[] { + return [...text.matchAll(WORD)] + .filter((match) => matches(match[0], terms)) + .map((match) => [match.index, match.index + match[0].length]); } -export function highlight(text: string, terms: string[]): Segment[] { - const shown = windowed(text, terms); - const segments: Segment[] = []; - let last = 0; - for (const match of shown.matchAll(WORD)) { - if (!matches(match[0], terms)) { +function markRanges(text: string, marks: string[]): Range[] { + const folded = fold(text); + if (folded.length !== text.length) { + return []; + } + const ranges: Range[] = []; + for (const mark of marks.map(fold)) { + if (mark.length < PREFIX_MIN) { + ranges.push(...wordRanges(text, [mark])); continue; } - if (match.index > last) { - segments.push({ hit: false, text: shown.slice(last, match.index) }); + for (let at = folded.indexOf(mark); at !== -1; ) { + ranges.push([at, at + mark.length]); + at = folded.indexOf(mark, at + mark.length); } - segments.push({ hit: true, text: match[0] }); - last = match.index + match[0].length; } - if (last < shown.length) { - segments.push({ hit: false, text: shown.slice(last) }); + ranges.sort((a, b) => a[0] - b[0]); + const merged: Range[] = []; + for (const [start, end] of ranges) { + const last = merged.at(-1); + if (last && start <= last[1]) { + last[1] = Math.max(last[1], end); + } else { + merged.push([start, end]); + } + } + return merged; +} + +function split(text: string, ranges: Range[]): Segment[] { + const segments: Segment[] = []; + let last = 0; + for (const [start, end] of ranges) { + if (start > last) { + segments.push({ hit: false, text: text.slice(last, start) }); + } + segments.push({ hit: true, text: text.slice(start, end) }); + last = end; + } + if (last < text.length) { + segments.push({ hit: false, text: text.slice(last) }); } return segments; } + +function windowed(text: string, ranges: Range[]): Segment[] { + const first = ranges[0]?.[0]; + if (first === undefined || first <= REACH) { + return split(text, ranges); + } + const cut = first - LEAD; + let start = cut; + while (start > 0 && !SPACE.test(text[start - 1])) { + start -= 1; + } + if (start === 0) { + start = cut; + } + const shifted = ranges.map(([a, b]): Range => [a - start, b - start]); + return [{ hit: false, text: "…" }, ...split(text.slice(start), shifted)]; +} + +export function highlight(text: string, terms: string[]): Segment[] { + return windowed(text, wordRanges(text, terms)); +} + +export function highlightMarks(text: string, marks: string[]): Segment[] { + return windowed(text, markRanges(text, marks)); +} + +export function markText(text: string, marks: string[]): Segment[] { + return split(text, markRanges(text, marks)); +} + +export function hasMark(text: string | null, marks: string[]): boolean { + return text !== null && markRanges(text, marks).length > 0; +} + +export function buttonHasMark(button: InlineButton, marks: string[]): boolean { + return [button.text, button.data, button.url].some((value) => + hasMark(value, marks) + ); +} diff --git a/frontend/src/lib/format/watches.ts b/frontend/src/lib/format/watches.ts index 673e841..b533611 100644 --- a/frontend/src/lib/format/watches.ts +++ b/frontend/src/lib/format/watches.ts @@ -9,6 +9,7 @@ import type { } from "#lib/api/watches.js"; import { formatTime } from "#lib/format/datetime.js"; import { locale, t } from "#lib/i18n/locale.svelte.js"; +import { MARK_PARAM } from "#lib/stores/feed.svelte.js"; export const KIND_ICONS: Record = { keyword: "search", @@ -162,6 +163,32 @@ export function alertTitle(alert: AlertItem): string { return personName(alert.peer); } +function alertMarks(alert: AlertItem): string[] { + const pattern = patternOf(alert.watch.params); + if (alert.watch.kind !== "keyword" || !pattern) { + return []; + } + if (!alert.watch.params.regex) { + return [pattern]; + } + try { + const found = (alert.message?.text ?? "").matchAll( + new RegExp(pattern, "gi") + ); + return [...new Set([...found].map((match) => match[0]).filter(Boolean))]; + } catch { + return []; + } +} + +export function alertHref(alert: AlertItem): string { + const url = new URL(alert.path, "http://app"); + for (const mark of alertMarks(alert)) { + url.searchParams.append(MARK_PARAM, mark); + } + return `${url.pathname}${url.search}`; +} + export function alertLabel(alert: AlertItem): string { if (alert.watch.name) { return alert.watch.name; diff --git a/frontend/src/lib/i18n/messages/search.ts b/frontend/src/lib/i18n/messages/search.ts index 2bad461..1ef893c 100644 --- a/frontend/src/lib/i18n/messages/search.ts +++ b/frontend/src/lib/i18n/messages/search.ts @@ -13,10 +13,36 @@ export const search = { "search.clear": { en: "Clear", ru: "Очистить" }, "search.deleted": { en: "Deleted", ru: "Удалённые" }, "search.edited": { en: "Edited", ru: "Изменённые" }, + "search.everywhere": { en: "Search everywhere", ru: "Искать везде" }, + "search.everywhereHint": { + en: "In buttons, links and message data", + ru: "В кнопках, ссылках и данных сообщений", + }, + "search.everywhereNothing": { + en: "Nothing else anywhere", + ru: "Больше нигде не нашлось", + }, + "search.everywhereScanned": { + en: "Searching, back to {date}", + ru: "Ищу, проверено до {date}", + }, + "search.everywhereScanning": { + en: "Searching everywhere", + ru: "Ищу везде", + }, + "search.everywhereSection": { en: "Everywhere", ru: "Везде" }, "search.failed": { en: "Search did not respond", ru: "Поиск не ответил", }, + "search.found.button": { en: "Button “{text}”", ru: "Кнопка «{text}»" }, + "search.found.data": { en: "Message data", ru: "Данные сообщения" }, + "search.found.file": { en: "File name", ru: "Имя файла" }, + "search.found.forward": { en: "Forwarded from", ru: "Переслано от" }, + "search.found.link": { en: "Link", ru: "Ссылка" }, + "search.found.preview": { en: "Link preview", ru: "Превью ссылки" }, + "search.found.reply": { en: "Reply", ru: "Ответ" }, + "search.found.text": { en: "Text", ru: "Текст" }, "search.from": { en: "From", ru: "От кого" }, "search.fromName": { en: "Name or @username", ru: "Имя или @username" }, "search.inChat": { en: "Search this chat", ru: "Поиск в чате" }, diff --git a/frontend/src/lib/stores/alerts.svelte.ts b/frontend/src/lib/stores/alerts.svelte.ts index a707a79..45c2409 100644 --- a/frontend/src/lib/stores/alerts.svelte.ts +++ b/frontend/src/lib/stores/alerts.svelte.ts @@ -3,7 +3,7 @@ import { type LiveAlertEvent, unseenAlerts, } from "#lib/api/watches.js"; -import { alertLine, alertTitle } from "#lib/format/watches.js"; +import { alertHref, alertLine, alertTitle } from "#lib/format/watches.js"; import { events } from "#lib/stores/events.svelte.js"; import { browser } from "$app/env"; import { goto } from "$app/navigation"; @@ -49,7 +49,7 @@ function createAlerts() { }); popup.onclick = () => { window.focus(); - goto(alert.path); + goto(alertHref(alert)); popup.close(); }; } diff --git a/frontend/src/lib/stores/feed.svelte.ts b/frontend/src/lib/stores/feed.svelte.ts index 98f3b3e..c51324f 100644 --- a/frontend/src/lib/stores/feed.svelte.ts +++ b/frontend/src/lib/stores/feed.svelte.ts @@ -9,6 +9,7 @@ const LIVE_DELAY = 600; const KIND_PARAM = "feed"; const CHATS_PARAM = "chats"; const MESSAGE_PARAM = "message"; +export const MARK_PARAM = "mark"; export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"]; export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"]; @@ -72,6 +73,7 @@ export function messageHref( export function withoutMessage(url: UrlLike): string { const next = new URL(url.href); next.searchParams.delete(MESSAGE_PARAM); + next.searchParams.delete(MARK_PARAM); return href(next); } diff --git a/frontend/src/lib/stores/search.svelte.ts b/frontend/src/lib/stores/search.svelte.ts index 629a7d4..020fe29 100644 --- a/frontend/src/lib/stores/search.svelte.ts +++ b/frontend/src/lib/stores/search.svelte.ts @@ -11,6 +11,7 @@ import type { 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"; @@ -30,6 +31,7 @@ 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(["private", "group", "channel"]); const DAY = /^\d{4}-\d{2}-\d{2}$/; const PARAMS = [ @@ -41,6 +43,7 @@ const PARAMS = [ "from", "since", "until", + "everywhere", ] as const; type Param = (typeof PARAMS)[number]; @@ -90,6 +93,7 @@ export function createHitPager() { let hits = $state([]); let terms = $state([]); let cursor = $state(null); + let scannedTo = $state(null); let loading = $state(false); let appending = $state(false); let failed = $state(false); @@ -106,6 +110,7 @@ export function createHitPager() { hits = request.cursor ? [...hits, ...result.hits] : result.hits; ({ terms } = result); cursor = result.next_cursor; + scannedTo = result.scanned_to; } catch { if (current === seq) { failed = true; @@ -122,6 +127,7 @@ export function createHitPager() { seq += 1; base = request; cursor = null; + scannedTo = null; failed = false; appending = false; loading = request !== null; @@ -160,12 +166,21 @@ export function createHitPager() { run(base); }, run, + get scannedTo() { + return scannedTo; + }, get terms() { return terms; }, }; } +export type HitPager = ReturnType; + +export function hitKey(hit: SearchHit): string { + return `${hit.chat_id}:${hit.message_id}`; +} + function createSearch() { let open = $state(false); let draft = $state(""); @@ -177,7 +192,9 @@ function createSearch() { 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") ?? ""); @@ -192,6 +209,14 @@ function createSearch() { } 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)); @@ -272,7 +297,9 @@ function createSearch() { stopTyping(); lastRequest = ""; lastText = ""; + lastEverywhere = ""; messages.run(null); + everywhere.run(null); runText(""); } @@ -288,14 +315,21 @@ function createSearch() { 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): string { + 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) { @@ -315,6 +349,9 @@ function createSearch() { return request; }, reset, + searchEverywhere() { + write({ everywhere: "1" }).catch(() => undefined); + }, setFilter(key: "from" | "kind" | "since" | "until", value: string | null) { write({ [key]: value }).catch(() => undefined); }, @@ -329,7 +366,11 @@ function createSearch() { } timer = setTimeout(() => { timer = null; - write({ q: draft.trim() ? draft : 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) { @@ -352,6 +393,12 @@ function createSearch() { runText(value); } }, + syncEverywhere(key: string) { + if (key !== lastEverywhere) { + lastEverywhere = key; + everywhere.run(key ? (JSON.parse(key)[1] as SearchQuery) : null); + } + }, get text() { return text; }, diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index 05c4183..d78afc5 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -36,6 +36,7 @@ export interface PanelState { interface JumpTarget { chatId: number; + marks: string[]; messageId: number; } @@ -141,8 +142,8 @@ function createUi() { : null; }, record, - requestJump(chatId: number, messageId: number) { - jumpTarget = { chatId, messageId }; + requestJump(chatId: number, messageId: number, marks: string[] = []) { + jumpTarget = { chatId, marks, messageId }; }, resetPanel() { panel = null; diff --git a/frontend/src/routes/app/[chatId]/+page.svelte b/frontend/src/routes/app/[chatId]/+page.svelte index 4e81e5f..3488169 100644 --- a/frontend/src/routes/app/[chatId]/+page.svelte +++ b/frontend/src/routes/app/[chatId]/+page.svelte @@ -4,7 +4,7 @@ import MessageList from "#lib/components/MessageList.svelte"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js"; - import { withoutMessage } from "#lib/stores/feed.svelte.js"; + import { MARK_PARAM, withoutMessage } from "#lib/stores/feed.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js"; import { goto } from "$app/navigation"; import { page } from "$app/state"; @@ -27,7 +27,11 @@ const messageId = Number(linkedMessage); untrack(() => { if (Number.isSafeInteger(messageId)) { - ui.requestJump(chatId, messageId); + ui.requestJump( + chatId, + messageId, + page.url.searchParams.getAll(MARK_PARAM) + ); } goto(withoutMessage(page.url), { replace: true, reset: false }); });