Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc60021f57 | ||
|
|
d660079c02 | ||
|
|
acfa726e61 |
No files matched your search
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from utils.files import content_disposition, story_file_name
|
||||
from utils.files import content_disposition, sniff_media_kind, story_file_name
|
||||
from utils.read import peers
|
||||
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
|
||||
from utils.storage import ContentAddressedStorage
|
||||
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
|
||||
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
|
||||
|
||||
|
||||
async def _with_kind(
|
||||
pool: asyncpg.Pool,
|
||||
storage: ContentAddressedStorage,
|
||||
account_id: int,
|
||||
story: StoryView,
|
||||
) -> StoryView:
|
||||
if story.media_kind or not story.storage_key:
|
||||
return story
|
||||
if not storage.exists(story.storage_key):
|
||||
return story
|
||||
kind = sniff_media_kind(storage.head(story.storage_key))
|
||||
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
|
||||
return story.model_copy(update={"media_kind": kind})
|
||||
|
||||
|
||||
@router.get("/stories")
|
||||
async def list_stories(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
storage: FromDishka[ContentAddressedStorage],
|
||||
account_id: AccountId,
|
||||
peer_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
) -> list[StoryView]:
|
||||
return await peers.get_stories(
|
||||
stories = await peers.get_stories(
|
||||
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
|
||||
)
|
||||
return [await _with_kind(pool, storage, account_id, story) for story in stories]
|
||||
|
||||
|
||||
@router.get("/stories/{peer_id}/{story_id}/media")
|
||||
|
||||
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
|
||||
INSERT INTO stories
|
||||
(account_id, peer_id, story_id, date, expire_date, caption, media_kind,
|
||||
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now())
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now())
|
||||
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
|
||||
date = EXCLUDED.date,
|
||||
expire_date = EXCLUDED.expire_date,
|
||||
@@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
|
||||
downloaded = stories.downloaded OR EXCLUDED.downloaded,
|
||||
views = EXCLUDED.views,
|
||||
pinned = EXCLUDED.pinned,
|
||||
deleted = EXCLUDED.deleted,
|
||||
deleted = false,
|
||||
raw = EXCLUDED.raw
|
||||
"""
|
||||
|
||||
_MARK_DELETED = """
|
||||
INSERT INTO stories
|
||||
(account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at)
|
||||
VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now())
|
||||
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true
|
||||
"""
|
||||
|
||||
|
||||
async def is_downloaded(
|
||||
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
|
||||
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
|
||||
raw: str,
|
||||
*,
|
||||
pinned: bool,
|
||||
deleted: bool,
|
||||
downloaded: bool,
|
||||
) -> None:
|
||||
await pool.execute(
|
||||
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
|
||||
downloaded,
|
||||
views,
|
||||
pinned,
|
||||
deleted,
|
||||
raw,
|
||||
)
|
||||
|
||||
|
||||
async def mark_deleted(
|
||||
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
|
||||
) -> None:
|
||||
await pool.execute(_MARK_DELETED, account_id, peer_id, story_id)
|
||||
@@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int:
|
||||
|
||||
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
|
||||
peer_id = story_peer_id(story)
|
||||
if story.deleted:
|
||||
await repository.mark_deleted(
|
||||
capture.pool, capture.account_id, peer_id, story.id
|
||||
)
|
||||
return
|
||||
storage_key: str | None = None
|
||||
file_size: int | None = None
|
||||
downloaded = False
|
||||
stored = await repository.is_downloaded(
|
||||
capture.pool, capture.account_id, peer_id, story.id
|
||||
)
|
||||
if not (stored or story.deleted or story.media is None):
|
||||
if not (stored or story.media is None):
|
||||
data = await download_bytes(client, story)
|
||||
if data is not None:
|
||||
storage_key = capture.storage.put(data)
|
||||
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
|
||||
story.views,
|
||||
raw_json(story.raw),
|
||||
pinned=bool(story.pinned),
|
||||
deleted=bool(story.deleted),
|
||||
downloaded=downloaded,
|
||||
)
|
||||
@@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
|
||||
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
|
||||
|
||||
|
||||
def sniff_media_kind(head: bytes) -> str:
|
||||
is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3")
|
||||
return "video" if is_video else "photo"
|
||||
|
||||
|
||||
def resolve_mime(
|
||||
kind: str | None, mime: str | None, file_name: str | None = None
|
||||
) -> str:
|
||||
|
||||
@@ -76,7 +76,7 @@ async def get_stories(
|
||||
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
|
||||
"storage_key, downloaded, views, pinned, deleted FROM stories "
|
||||
f"WHERE {where} "
|
||||
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} "
|
||||
f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} "
|
||||
f"OFFSET ${len(params)}",
|
||||
*params,
|
||||
)
|
||||
@@ -95,3 +95,16 @@ async def get_story(
|
||||
story_id,
|
||||
)
|
||||
return StoryView(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def set_story_kind(
|
||||
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str
|
||||
) -> None:
|
||||
await pool.execute(
|
||||
"UPDATE stories SET media_kind = $4 "
|
||||
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
|
||||
account_id,
|
||||
peer_id,
|
||||
story_id,
|
||||
kind,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,10 @@ class ContentAddressedStorage:
|
||||
def get(self, key: str) -> bytes:
|
||||
return self._path(key).read_bytes()
|
||||
|
||||
def head(self, key: str, size: int = 16) -> bytes:
|
||||
with self._path(key).open("rb") as file:
|
||||
return file.read(size)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
return self._path(key).exists()
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
<mark class="found" class:own>{segment.text}</mark>
|
||||
{: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;
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { InlineButton } from "#lib/api/types.js";
|
||||
import { buttonHasMark } from "#lib/format/search.js";
|
||||
import { safeHref } from "#lib/format/url.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
|
||||
interface Props {
|
||||
marks?: string[];
|
||||
rows: InlineButton[][];
|
||||
}
|
||||
|
||||
let { rows }: Props = $props();
|
||||
let { marks = [], rows }: Props = $props();
|
||||
|
||||
let copied = $state<string | null>(null);
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -27,12 +29,14 @@
|
||||
<div class="row">
|
||||
{#each row as button, colIndex (colIndex)}
|
||||
{@const key = `${rowIndex}:${colIndex}`}
|
||||
{@const lit = marks.length > 0 && buttonHasMark(button, marks)}
|
||||
{#if button.kind === "url" && button.url}
|
||||
<a
|
||||
class="button"
|
||||
href={safeHref(button.url)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class:found={lit}
|
||||
>
|
||||
<span class="label">{button.text}</span>
|
||||
<span class="corner">↗</span>
|
||||
@@ -42,13 +46,14 @@
|
||||
class="button"
|
||||
onclick={() => copy(key, button.data ?? "")}
|
||||
type="button"
|
||||
class:found={lit}
|
||||
>
|
||||
<span class="label"
|
||||
>{copied === key ? t("inline.copied") : button.text}</span
|
||||
>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="button static">
|
||||
<span class="button static" class:found={lit}>
|
||||
<span class="label">{button.text}</span>
|
||||
</span>
|
||||
{/if}
|
||||
@@ -104,6 +109,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.found {
|
||||
animation: found-flash 3s ease;
|
||||
}
|
||||
|
||||
@keyframes found-flash {
|
||||
0%,
|
||||
70% {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 30%,
|
||||
var(--color-message-reaction)
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: var(--color-message-reaction);
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import WebPage from "#lib/components/WebPage.svelte";
|
||||
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
|
||||
import { buttonHasMark, hasMark } from "#lib/format/search.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
@@ -31,7 +32,7 @@
|
||||
interface Props {
|
||||
animate: boolean;
|
||||
firstInGroup: boolean;
|
||||
highlighted: boolean;
|
||||
highlight: string[] | null;
|
||||
isGroupChat: boolean;
|
||||
lastInGroup: boolean;
|
||||
message: MessageView;
|
||||
@@ -47,7 +48,7 @@
|
||||
isGroupChat,
|
||||
firstInGroup,
|
||||
lastInGroup,
|
||||
highlighted,
|
||||
highlight,
|
||||
animate,
|
||||
onjump,
|
||||
onmedia,
|
||||
@@ -55,6 +56,14 @@
|
||||
}: Props = $props();
|
||||
|
||||
const deleted = $derived(message.deleted_at !== null);
|
||||
const marks = $derived(highlight ?? []);
|
||||
const markedInside = $derived(
|
||||
marks.length > 0 &&
|
||||
(hasMark(message.text, marks) ||
|
||||
message.inline_buttons.some((row) =>
|
||||
row.some((button) => buttonHasMark(button, marks))
|
||||
))
|
||||
);
|
||||
const hasText = $derived(Boolean(message.text));
|
||||
const special = $derived(
|
||||
Boolean(
|
||||
@@ -162,7 +171,7 @@
|
||||
data-message-id={message.message_id}
|
||||
class:deleted
|
||||
class:first-in-group={firstInGroup}
|
||||
class:highlighted
|
||||
class:highlighted={highlight !== null && !markedInside}
|
||||
class:last-in-group={lastInGroup}
|
||||
class:own
|
||||
class:with-avatar={isGroupChat}
|
||||
@@ -239,6 +248,7 @@
|
||||
<div class="text">
|
||||
<EntityText
|
||||
entities={message.entities}
|
||||
{marks}
|
||||
{own}
|
||||
text={message.text ?? ""}
|
||||
/>
|
||||
@@ -270,7 +280,7 @@
|
||||
{own}
|
||||
/>
|
||||
{#if message.inline_buttons.length}
|
||||
<InlineButtons rows={message.inline_buttons} />
|
||||
<InlineButtons {marks} rows={message.inline_buttons} />
|
||||
{/if}
|
||||
{#if hasCallbacks}
|
||||
<button class="detail-chip" onclick={openCallbacks} type="button">
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
const IDLE_DELAY = 1500;
|
||||
const MAX_MESSAGES = 360;
|
||||
const SETTLE_DELAY = 400;
|
||||
const HIGHLIGHT_MS = 3000;
|
||||
|
||||
let messages = $state<MessageView[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -57,7 +58,7 @@
|
||||
let mediaIndex = $state(0);
|
||||
let versionsOpen = $state(false);
|
||||
let versionsMessageId = $state<number | null>(null);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let highlight = $state<{ id: number; marks: string[] } | null>(null);
|
||||
let calendarOpen = $state(false);
|
||||
let calendarAnchor = $state<HTMLElement | null>(null);
|
||||
let calendarMonth = $state<{ month: number; year: number } | null>(null);
|
||||
@@ -229,7 +230,11 @@
|
||||
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
|
||||
}
|
||||
|
||||
function jumpToMessage(messageId: number, smooth = true) {
|
||||
function jumpToMessage(
|
||||
messageId: number,
|
||||
smooth = true,
|
||||
marks: string[] = []
|
||||
) {
|
||||
const owner = ownerOf(messageId) ?? messageId;
|
||||
const target = container?.querySelector<HTMLElement>(
|
||||
`[data-message-id="${owner}"]`
|
||||
@@ -242,13 +247,13 @@
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
block: "center",
|
||||
});
|
||||
highlightId = owner;
|
||||
highlight = { id: owner, marks };
|
||||
if (highlightTimer) {
|
||||
clearTimeout(highlightTimer);
|
||||
}
|
||||
highlightTimer = setTimeout(() => {
|
||||
highlightId = null;
|
||||
}, 1600);
|
||||
highlight = null;
|
||||
}, HIGHLIGHT_MS);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
@@ -283,7 +288,7 @@
|
||||
ui.clearJump();
|
||||
await loadAround(pending.messageId);
|
||||
await tick();
|
||||
jumpToMessage(pending.messageId, false);
|
||||
jumpToMessage(pending.messageId, false, pending.marks);
|
||||
return;
|
||||
}
|
||||
const page = await listMessages(chatId, {
|
||||
@@ -574,14 +579,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function goTo(messageId: number) {
|
||||
async function goTo(messageId: number, marks: string[] = []) {
|
||||
try {
|
||||
const loaded = ownerOf(messageId) !== null;
|
||||
if (!loaded) {
|
||||
await loadAround(messageId);
|
||||
}
|
||||
await tick();
|
||||
jumpToMessage(messageId, loaded);
|
||||
jumpToMessage(messageId, loaded, marks);
|
||||
} catch {
|
||||
toasts.error(t("thread.loadFailed"));
|
||||
}
|
||||
@@ -593,7 +598,7 @@
|
||||
return;
|
||||
}
|
||||
ui.clearJump();
|
||||
goTo(target.messageId);
|
||||
goTo(target.messageId, target.marks);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -663,7 +668,9 @@
|
||||
<MessageBubble
|
||||
animate={!suppressAppear}
|
||||
firstInGroup={row.firstInGroup}
|
||||
highlighted={highlightId === row.message.message_id}
|
||||
highlight={highlight?.id === row.message.message_id
|
||||
? highlight.marks
|
||||
: null}
|
||||
{isGroupChat}
|
||||
lastInGroup={row.lastInGroup}
|
||||
message={row.message}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||
import {
|
||||
alertAvatar,
|
||||
alertHref,
|
||||
alertLabel,
|
||||
alertPreview,
|
||||
alertTitle,
|
||||
@@ -131,7 +132,7 @@
|
||||
if (isMobile()) {
|
||||
ui.resetPanel();
|
||||
}
|
||||
await goto(alert.path);
|
||||
await goto(alertHref(alert));
|
||||
}
|
||||
|
||||
async function seeAll() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { visible } from "#lib/actions/visible.js";
|
||||
import SearchEverywhere from "#lib/components/search/SearchEverywhere.svelte";
|
||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||
@@ -15,7 +16,9 @@
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
const pager = createHitPager();
|
||||
const everywhere = createHitPager();
|
||||
let query = $state("");
|
||||
let deep = $state(false);
|
||||
let typing = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -23,14 +26,30 @@
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
const needle = $derived(query.trim());
|
||||
const empty = $derived(
|
||||
needle !== "" &&
|
||||
!(typing || deep || pager.loading || pager.failed) &&
|
||||
pager.hits.length === 0
|
||||
);
|
||||
|
||||
function run() {
|
||||
typing = false;
|
||||
const value = query.trim();
|
||||
deep = false;
|
||||
everywhere.run(null);
|
||||
pager.run(
|
||||
value && chatId !== null ? { chat_id: chatId, query: value } : null
|
||||
needle && chatId !== null ? { chat_id: chatId, query: needle } : null
|
||||
);
|
||||
}
|
||||
|
||||
function searchEverywhere() {
|
||||
if (chatId === null) {
|
||||
return;
|
||||
}
|
||||
deep = true;
|
||||
everywhere.run({ chat_id: chatId, everywhere: true, query: needle });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chatId !== null) {
|
||||
untrack(run);
|
||||
@@ -46,11 +65,11 @@
|
||||
timer = setTimeout(run, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function openHit(messageId: number) {
|
||||
function openHit(messageId: number, marks: string[]) {
|
||||
if (chatId === null) {
|
||||
return;
|
||||
}
|
||||
ui.requestJump(chatId, messageId);
|
||||
ui.requestJump(chatId, messageId, marks);
|
||||
if (isMobile()) {
|
||||
ui.closePanel();
|
||||
}
|
||||
@@ -73,9 +92,9 @@
|
||||
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id)}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id, pager.terms)}
|
||||
inChat
|
||||
onclick={() => openHit(hit.message_id)}
|
||||
onclick={() => openHit(hit.message_id, pager.terms)}
|
||||
terms={pager.terms}
|
||||
/>
|
||||
{/each}
|
||||
@@ -96,7 +115,19 @@
|
||||
>{t("search.retry")}</Button
|
||||
>
|
||||
</div>
|
||||
{:else if query.trim() && !typing}
|
||||
{/if}
|
||||
<SearchEverywhere
|
||||
href={(hit) => search.hitHref(hit.chat_id, hit.message_id, [needle])}
|
||||
inChat
|
||||
{needle}
|
||||
onopen={(hit) => openHit(hit.message_id, [needle])}
|
||||
onstart={searchEverywhere}
|
||||
pager={everywhere}
|
||||
ready={!(typing || pager.loading || pager.failed || pager.hasMore)}
|
||||
shown={pager.hits}
|
||||
started={deep}
|
||||
/>
|
||||
{#if empty}
|
||||
<EmptyState title={t("search.nothing")} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import { visible } from "#lib/actions/visible.js";
|
||||
import type { SearchHit } from "#lib/api/types.js";
|
||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||
import { formatDate } from "#lib/format/datetime.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import {
|
||||
EVERYWHERE_MIN,
|
||||
type HitPager,
|
||||
hitKey,
|
||||
} from "#lib/stores/search.svelte.js";
|
||||
|
||||
interface Props {
|
||||
href: (hit: SearchHit) => string;
|
||||
inChat?: boolean;
|
||||
needle: string;
|
||||
onopen?: (hit: SearchHit) => void;
|
||||
onstart: () => void;
|
||||
pager: HitPager;
|
||||
ready: boolean;
|
||||
shown: SearchHit[];
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
href,
|
||||
inChat = false,
|
||||
needle,
|
||||
onopen,
|
||||
onstart,
|
||||
pager,
|
||||
ready,
|
||||
shown,
|
||||
started,
|
||||
}: Props = $props();
|
||||
|
||||
const hits = $derived.by(() => {
|
||||
const seen = new Set(shown.map(hitKey));
|
||||
return pager.hits.filter((hit) => {
|
||||
const key = hitKey(hit);
|
||||
const fresh = !seen.has(key);
|
||||
seen.add(key);
|
||||
return fresh;
|
||||
});
|
||||
});
|
||||
const busy = $derived(pager.loading || pager.appending);
|
||||
|
||||
function retry() {
|
||||
if (pager.hasMore) {
|
||||
pager.more();
|
||||
} else {
|
||||
pager.retry();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !started}
|
||||
{#if ready && needle.length >= EVERYWHERE_MIN}
|
||||
<button
|
||||
class="start"
|
||||
onclick={onstart}
|
||||
type="button"
|
||||
class:in-chat={inChat}
|
||||
use:ripple
|
||||
>
|
||||
<span class="glyph"><Icon name="search" size="1.5rem" /></span>
|
||||
<span class="body">
|
||||
<span class="label">{t("search.everywhere")}</span>
|
||||
<span class="hint">{t("search.everywhereHint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if hits.length > 0}
|
||||
<div class="section-label">{t("search.everywhereSection")}</div>
|
||||
{#each hits as hit (hitKey(hit))}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
href={href(hit)}
|
||||
{inChat}
|
||||
onclick={() => onopen?.(hit)}
|
||||
terms={[needle]}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if busy}
|
||||
<div class="status">
|
||||
<Spinner size="1.25rem" />
|
||||
<span>
|
||||
{pager.scannedTo
|
||||
? t("search.everywhereScanned", { date: formatDate(pager.scannedTo) })
|
||||
: t("search.everywhereScanning")}
|
||||
</span>
|
||||
</div>
|
||||
{:else if pager.failed}
|
||||
<div class="status failed">
|
||||
<span>{t("search.failed")}</span>
|
||||
<Button onclick={retry} variant="text">{t("search.retry")}</Button>
|
||||
</div>
|
||||
{:else if pager.hasMore}
|
||||
<div class="sentinel" use:visible={() => pager.more()}></div>
|
||||
{:else if hits.length === 0}
|
||||
<div class="status">{t("search.everywhereNothing")}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.start {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
|
||||
color: var(--color-primary);
|
||||
|
||||
.in-chat & {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
padding: 0.625rem 0.75rem 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding: 1rem 0.75rem 1.5rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.failed {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import type { SearchHit } from "#lib/api/types.js";
|
||||
import type { FoundWhere, SearchHit } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||
import { mediaKindLabel } from "#lib/format/media.js";
|
||||
import { peerName } from "#lib/format/peer.js";
|
||||
import { highlight } from "#lib/format/search.js";
|
||||
import { highlight, highlightMarks } from "#lib/format/search.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
@@ -22,6 +22,17 @@
|
||||
|
||||
let { hit, href, inChat = false, onclick, terms }: Props = $props();
|
||||
|
||||
const FOUND_ICONS: Record<FoundWhere, string> = {
|
||||
button: "bot-command",
|
||||
data: "info",
|
||||
file: "document",
|
||||
forward: "forward",
|
||||
link: "link",
|
||||
preview: "link",
|
||||
reply: "reply",
|
||||
text: "search",
|
||||
};
|
||||
|
||||
const chat = $derived(chats.byId(hit.chat_id));
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
|
||||
@@ -81,7 +92,21 @@
|
||||
const kind = $derived(
|
||||
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
|
||||
);
|
||||
const segments = $derived(highlight(body, terms));
|
||||
const byData = $derived(hit.source === "data");
|
||||
const segments = $derived(
|
||||
byData ? highlightMarks(body, terms) : highlight(body, terms)
|
||||
);
|
||||
const found = $derived(
|
||||
hit.found && hit.found.where !== "text" ? hit.found : null
|
||||
);
|
||||
const foundLabel = $derived.by(() => {
|
||||
if (found === null) {
|
||||
return "";
|
||||
}
|
||||
return found.where === "button"
|
||||
? t("search.found.button", { text: found.button ?? "" })
|
||||
: t(`search.found.${found.where}`);
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="hit" {href} {onclick} use:ripple>
|
||||
@@ -126,6 +151,21 @@
|
||||
{/each}</span
|
||||
>
|
||||
</div>
|
||||
{#if found}
|
||||
<div class="note found" title={found.path}>
|
||||
<Icon name={FOUND_ICONS[found.where]} size="0.875rem" />
|
||||
<span class="found-label">{foundLabel}:</span>
|
||||
<span class="found-value"
|
||||
>{#each highlightMarks(found.value, terms) as segment, index (index)}
|
||||
{#if segment.hit}
|
||||
<mark>{segment.text}</mark>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hit.source === "version"}
|
||||
<div
|
||||
class="note"
|
||||
@@ -244,6 +284,22 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.found {
|
||||
font-style: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.found-label {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.found-value {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0 0.0625rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { visible } from "#lib/actions/visible.js";
|
||||
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
||||
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
|
||||
import SearchEverywhere from "#lib/components/search/SearchEverywhere.svelte";
|
||||
import SearchFilters from "#lib/components/search/SearchFilters.svelte";
|
||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
@@ -14,7 +15,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const { messages } = search;
|
||||
const { everywhere, messages } = search;
|
||||
let scroller = $state<HTMLDivElement | null>(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}`)}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id)}
|
||||
onclick={() => 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 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchEverywhere
|
||||
href={(hit) => 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}
|
||||
<EmptyState title={t("search.nothing")} />
|
||||
{/if}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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<WatchKind, string> = {
|
||||
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;
|
||||
|
||||
@@ -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: "Поиск в чате" },
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>(["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<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);
|
||||
@@ -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<typeof createHitPager>;
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -159,6 +159,12 @@ html.theme-transition * {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-size: max(1em, 1rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-menu-content {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user