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 });
});