Compare commits

...
7 Commits
33 changed files with 2405 additions and 340 deletions

No files matched your search

@@ -0,0 +1,321 @@
"""feed of profile changes and chat renames
Revision ID: c5e7a9b1d3f6
Revises: b3d5f7a9c2e4
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c5e7a9b1d3f6"
down_revision: str | None = "b3d5f7a9c2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_PEER_VALUE = """
CREATE FUNCTION feed_peer_value(
field text, first_name text, last_name text, username text, photo text
) RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT CASE field
WHEN 'name' THEN NULLIF(trim(concat_ws(' ', first_name, last_name)), '')
WHEN 'username' THEN NULLIF(username, '')
WHEN 'photo' THEN NULLIF(photo, '') END
$$
"""
_PEER_SCOPE = """
CREATE FUNCTION feed_peer_in_scope(account integer, peer bigint)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM dialogs WHERE account_id = account AND chat_id = peer
) OR EXISTS (
SELECT 1 FROM peers
WHERE account_id = account AND peer_id = peer
AND (raw->>'is_contact' = 'true' OR raw->>'contact' = 'true')
)
$$
"""
_MERGE_DETAIL = """
CREATE FUNCTION feed_merge_detail(base jsonb, incoming jsonb)
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
SELECT coalesce(jsonb_object_agg(key, pair), '{}'::jsonb)
FROM (
SELECT key, jsonb_build_array(
coalesce(base->key->0, incoming->key->0),
coalesce(incoming->key->1, base->key->1)) AS pair
FROM (
SELECT jsonb_object_keys(coalesce(base, '{}'::jsonb)) AS key
UNION
SELECT jsonb_object_keys(incoming)
) keys
) merged
WHERE pair->0 IS DISTINCT FROM pair->1
$$
"""
_UPSERT = """
CREATE FUNCTION feed_profile_upsert(
account integer, chat bigint, kind_of_chat text, message bigint,
changed_at timestamptz, changes jsonb
) RETURNS void LANGUAGE plpgsql AS $$
DECLARE
item feed_items%ROWTYPE;
merged jsonb;
BEGIN
SELECT * INTO item FROM feed_items
WHERE account_id = account AND chat_id = chat AND kind = 'profile'
ORDER BY at DESC, id DESC LIMIT 1;
IF FOUND AND changed_at BETWEEN item.since - interval '1 day'
AND item.at + interval '1 day' THEN
merged := feed_merge_detail(item.detail, changes);
IF merged = '{}'::jsonb THEN
DELETE FROM feed_items WHERE id = item.id;
ELSE
UPDATE feed_items SET
detail = merged,
message_count = message_count
+ (SELECT count(*) FROM jsonb_object_keys(changes)),
at = greatest(at, changed_at),
since = least(since, changed_at),
message_id = greatest(message_id, message)
WHERE id = item.id;
END IF;
ELSE
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
VALUES (account, 'profile', chat, kind_of_chat, message, changed_at,
changed_at,
(SELECT count(*) FROM jsonb_object_keys(changes)), changes);
END IF;
END;
$$
"""
_ON_PEER = """
CREATE FUNCTION feed_on_peer_change() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
changes jsonb := '{}'::jsonb;
field text;
fresh text;
old text;
BEGIN
IF NEW.peer_id <= 0 OR NOT feed_peer_in_scope(NEW.account_id, NEW.peer_id) THEN
RETURN NULL;
END IF;
FOREACH field IN ARRAY ARRAY['name', 'username', 'photo'] LOOP
fresh := feed_peer_value(field, NEW.first_name, NEW.last_name,
NEW.username, NEW.photo_unique_id);
CONTINUE WHEN fresh IS NULL;
SELECT value INTO old FROM (
SELECT feed_peer_value(field, h.first_name, h.last_name, h.username,
h.photo_unique_id) AS value, h.observed_at
FROM peer_history h
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
AND h.observed_at < NEW.observed_at
) earlier
WHERE value IS NOT NULL
ORDER BY observed_at DESC LIMIT 1;
CONTINUE WHEN old IS NULL OR old = fresh;
CONTINUE WHEN EXISTS (
SELECT 1 FROM peer_history h
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
AND h.observed_at < NEW.observed_at
AND feed_peer_value(field, h.first_name, h.last_name, h.username,
h.photo_unique_id) = fresh
);
changes := changes || jsonb_build_object(field, jsonb_build_array(old, fresh));
END LOOP;
IF changes <> '{}'::jsonb THEN
PERFORM feed_profile_upsert(NEW.account_id, NEW.peer_id, 'dm', 0,
NEW.observed_at, changes);
END IF;
RETURN NULL;
END;
$$
"""
_ON_TITLE = """
CREATE FUNCTION feed_on_chat_title() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
previous text;
BEGIN
IF NOT EXISTS (
SELECT 1 FROM dialogs
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
) THEN
RETURN NULL;
END IF;
SELECT title INTO previous FROM chat_history
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
AND event IN ('meta', 'title') AND title IS NOT NULL
AND (ts, message_id) < (NEW.ts, NEW.message_id)
ORDER BY ts DESC, message_id DESC LIMIT 1;
IF previous IS NULL OR previous = NEW.title THEN
RETURN NULL;
END IF;
PERFORM feed_profile_upsert(
NEW.account_id, NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
CASE WHEN NEW.event = 'title' THEN NEW.message_id ELSE 0 END,
NEW.ts, jsonb_build_object('title', jsonb_build_array(previous, NEW.title)));
RETURN NULL;
END;
$$
"""
_BACKFILL_PEERS = """
WITH scope AS (
SELECT account_id::integer AS account_id, chat_id AS peer_id
FROM dialogs WHERE chat_id > 0
UNION
SELECT account_id, peer_id FROM peers
WHERE raw->>'is_contact' = 'true' OR raw->>'contact' = 'true'
), firsts AS (
SELECT h.account_id, h.peer_id, f.field, f.value,
min(h.observed_at) AS at
FROM peer_history h
JOIN scope s ON s.account_id = h.account_id AND s.peer_id = h.peer_id
CROSS JOIN LATERAL (
SELECT field, feed_peer_value(field, h.first_name, h.last_name,
h.username, h.photo_unique_id) AS value
FROM unnest(ARRAY['name', 'username', 'photo']) AS field
) f
WHERE f.value IS NOT NULL
GROUP BY 1, 2, 3, 4
), ranked AS (
SELECT *, row_number() OVER (
PARTITION BY account_id, peer_id, field ORDER BY at
) AS position
FROM firsts
), changes AS (
SELECT r.account_id, r.peer_id, r.field, r.value AS fresh, r.at, o.value AS old
FROM ranked r
CROSS JOIN LATERAL (
SELECT value FROM (
SELECT feed_peer_value(r.field, h.first_name, h.last_name,
h.username, h.photo_unique_id) AS value,
h.observed_at
FROM peer_history h
WHERE h.account_id = r.account_id AND h.peer_id = r.peer_id
AND h.observed_at < r.at
) earlier
WHERE value IS NOT NULL
ORDER BY observed_at DESC LIMIT 1
) o
WHERE r.position > 1 AND r.at > now() - interval '90 days'
AND o.value <> r.value
), gaps AS (
SELECT *, at - lag(at) OVER (PARTITION BY account_id, peer_id ORDER BY at)
AS gap
FROM changes
), bursts AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
OVER (PARTITION BY account_id, peer_id ORDER BY at) AS burst
FROM gaps
), fields AS (
SELECT account_id, peer_id, burst, field,
(array_agg(old ORDER BY at))[1] AS old,
(array_agg(fresh ORDER BY at DESC))[1] AS fresh,
count(*) AS changes, min(at) AS since, max(at) AS at
FROM bursts GROUP BY 1, 2, 3, 4
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
SELECT account_id, 'profile', peer_id, 'dm', 0, max(at), min(since),
sum(changes)::integer,
jsonb_object_agg(field, jsonb_build_array(old, fresh))
FILTER (WHERE old <> fresh)
FROM fields
GROUP BY account_id, peer_id, burst
HAVING bool_or(old <> fresh)
ORDER BY max(at)
"""
_BACKFILL_CHATS = """
WITH titles AS (
SELECT account_id, chat_id, message_id, event, ts, title,
raw->'chat'->>'type' AS chat_type,
lag(title) OVER (
PARTITION BY account_id, chat_id ORDER BY ts, message_id
) AS previous
FROM chat_history
WHERE event IN ('meta', 'title') AND title IS NOT NULL
), changes AS (
SELECT * FROM titles t
WHERE previous IS NOT NULL AND previous <> title
AND ts > now() - interval '90 days'
AND EXISTS (
SELECT 1 FROM dialogs d
WHERE d.account_id = t.account_id AND d.chat_id = t.chat_id
)
), gaps AS (
SELECT *, ts - lag(ts) OVER (PARTITION BY account_id, chat_id ORDER BY ts)
AS gap
FROM changes
), bursts AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
OVER (PARTITION BY account_id, chat_id ORDER BY ts) AS burst
FROM gaps
), grouped AS (
SELECT account_id, chat_id, burst,
(array_agg(chat_type ORDER BY ts DESC))[1] AS chat_type,
max(CASE WHEN event = 'title' THEN message_id ELSE 0 END) AS message_id,
(array_agg(previous ORDER BY ts))[1] AS old,
(array_agg(title ORDER BY ts DESC))[1] AS fresh,
count(*) AS changes, min(ts) AS since, max(ts) AS at
FROM bursts GROUP BY 1, 2, 3
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
SELECT account_id, 'profile', chat_id, feed_chat_kind(chat_id, chat_type),
message_id, at, since, changes::integer,
jsonb_build_object('title', jsonb_build_array(old, fresh))
FROM grouped
WHERE old <> fresh
ORDER BY at
"""
def upgrade() -> None:
op.execute("SET LOCAL jit = off")
op.execute("ALTER TABLE feed_items ADD COLUMN detail jsonb")
op.execute(_PEER_VALUE)
op.execute(_PEER_SCOPE)
op.execute(_MERGE_DETAIL)
op.execute(_UPSERT)
op.execute(_ON_PEER)
op.execute(_ON_TITLE)
op.execute(
"CREATE TRIGGER peer_history_feed AFTER INSERT ON peer_history "
"FOR EACH ROW EXECUTE FUNCTION feed_on_peer_change()"
)
op.execute(
"CREATE TRIGGER chat_history_feed AFTER INSERT ON chat_history "
"FOR EACH ROW WHEN (NEW.event IN ('meta', 'title') AND NEW.title IS NOT NULL) "
"EXECUTE FUNCTION feed_on_chat_title()"
)
op.execute(_BACKFILL_PEERS)
op.execute(_BACKFILL_CHATS)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS chat_history_feed ON chat_history")
op.execute("DROP TRIGGER IF EXISTS peer_history_feed ON peer_history")
op.execute("DROP FUNCTION IF EXISTS feed_on_chat_title()")
op.execute("DROP FUNCTION IF EXISTS feed_on_peer_change()")
op.execute(
"DROP FUNCTION IF EXISTS "
"feed_profile_upsert(integer, bigint, text, bigint, timestamptz, jsonb)"
)
op.execute("DROP FUNCTION IF EXISTS feed_merge_detail(jsonb, jsonb)")
op.execute("DROP FUNCTION IF EXISTS feed_peer_in_scope(integer, bigint)")
op.execute("DROP FUNCTION IF EXISTS feed_peer_value(text, text, text, text, text)")
op.execute("DELETE FROM feed_items WHERE kind = 'profile'")
op.execute("ALTER TABLE feed_items DROP COLUMN detail")
@@ -0,0 +1,148 @@
"""message search index outside the hypertable, versions searchable
Revision ID: d8f1a6c3e5b7
Revises: c5e7a9b1d3f6
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f1a6c3e5b7"
down_revision: str | None = "c5e7a9b1d3f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_VECTOR = """
CREATE FUNCTION search_vector(body text) RETURNS tsvector
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT to_tsvector('russian', translate(coalesce(body, ''), 'Ёё', 'Ее'))
|| to_tsvector('simple', translate(coalesce(body, ''), 'Ёё', 'Ее'))
$$
"""
_TABLE = """
CREATE TABLE message_search (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
date timestamptz NOT NULL,
sender_id bigint,
text text,
has_media boolean NOT NULL DEFAULT false,
edited_at timestamptz,
deleted_at timestamptz,
tsv tsvector GENERATED ALWAYS AS (search_vector(text)) STORED,
PRIMARY KEY (account_id, chat_id, message_id, date)
)
"""
_BACKFILL = """
INSERT INTO message_search (account_id, chat_id, message_id, date, sender_id,
text, has_media, edited_at, deleted_at)
SELECT account_id, chat_id, message_id, date, sender_id,
text, has_media, edited_at, deleted_at
FROM messages
"""
_SYNC = """
CREATE FUNCTION message_search_sync() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM message_search
WHERE account_id = OLD.account_id AND chat_id = OLD.chat_id
AND message_id = OLD.message_id AND date = OLD.date;
RETURN NULL;
END IF;
INSERT INTO message_search (account_id, chat_id, message_id, date,
sender_id, text, has_media, edited_at,
deleted_at)
VALUES (NEW.account_id, NEW.chat_id, NEW.message_id, NEW.date,
NEW.sender_id, NEW.text, NEW.has_media, NEW.edited_at,
NEW.deleted_at)
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
sender_id = EXCLUDED.sender_id,
text = EXCLUDED.text,
has_media = EXCLUDED.has_media,
edited_at = EXCLUDED.edited_at,
deleted_at = EXCLUDED.deleted_at;
RETURN NULL;
END;
$$
"""
_CHANGED = (
"OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id "
"OR OLD.has_media IS DISTINCT FROM NEW.has_media "
"OR OLD.edited_at IS DISTINCT FROM NEW.edited_at "
"OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at"
)
def upgrade() -> None:
op.execute(_VECTOR)
op.execute(_TABLE)
op.execute(_BACKFILL)
op.execute("CREATE INDEX ix_message_search_tsv ON message_search USING gin (tsv)")
op.execute(
"CREATE INDEX ix_message_search_recent ON message_search "
"(account_id, date DESC, chat_id DESC, message_id DESC)"
)
op.execute(
"CREATE INDEX ix_message_search_sender ON message_search "
"(account_id, sender_id, date DESC)"
)
for flag in ("deleted", "edited"):
op.execute(
f"CREATE INDEX ix_message_search_{flag} ON message_search "
"(account_id, date DESC, chat_id DESC, message_id DESC) "
f"WHERE {flag}_at IS NOT NULL"
)
op.execute("ALTER TABLE message_search ALTER COLUMN tsv SET STATISTICS 1000")
op.execute(_SYNC)
op.execute(
"CREATE TRIGGER messages_search_insert AFTER INSERT ON messages "
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE TRIGGER messages_search_update AFTER UPDATE ON messages "
f"FOR EACH ROW WHEN ({_CHANGED}) EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE TRIGGER messages_search_delete AFTER DELETE ON messages "
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE INDEX ix_message_versions_tsv ON message_versions USING gin "
"(search_vector(text))"
)
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
op.execute(
"CREATE INDEX ix_media_tsv ON media USING gin (search_vector(extracted_text))"
)
op.execute("DROP INDEX IF EXISTS ix_messages_tsv")
for index in ("ix_message_versions_tsv", "ix_media_tsv"):
op.execute(f"ALTER INDEX {index} ALTER COLUMN 1 SET STATISTICS 1000")
op.execute("ANALYZE message_search, message_versions, media")
def downgrade() -> None:
op.execute(
"CREATE INDEX ix_messages_tsv ON messages USING gin "
"(to_tsvector('russian', coalesce(text, '')))"
)
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
op.execute(
"CREATE INDEX ix_media_tsv ON media USING gin "
"(to_tsvector('russian', coalesce(extracted_text, '')))"
)
op.execute("DROP INDEX IF EXISTS ix_message_versions_tsv")
op.execute("DROP TRIGGER IF EXISTS messages_search_delete ON messages")
op.execute("DROP TRIGGER IF EXISTS messages_search_update ON messages")
op.execute("DROP TRIGGER IF EXISTS messages_search_insert ON messages")
op.execute("DROP FUNCTION IF EXISTS message_search_sync()")
op.execute("DROP TABLE IF EXISTS message_search")
op.execute("DROP FUNCTION IF EXISTS search_vector(text)")
+45 -17
View File
@@ -38,7 +38,7 @@ from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
from utils.read.read_receipts import read_info from utils.read.read_receipts import read_info
from utils.search.models import SearchFilters from utils.search.models import ChatKind, SearchFilters
from utils.search.repository import search_messages from utils.search.repository import search_messages
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
@@ -72,13 +72,15 @@ Typical flows:
"📝 [your private note, NOT in Telegram]" are the user's own annotations "📝 [your private note, NOT in Telegram]" are the user's own annotations
attached locally via the web UI; they never existed in Telegram. Treat them attached locally via the web UI; they never existed in Telegram. Treat them
as private notes from the user to you, not as chat content. as private notes from the user to you, not as chat content.
- Find something: `search_messages_tool` (full-text over message text and STT - Find something: `search_messages_tool` (words by prefix over message text,
transcripts; supports chat/sender/date filters and regex). STT transcripts and earlier versions of edited messages; filters by chat,
sender, chat kind, media, deleted, edited, dates, regex).
- People: `get_person` gives one person in full: profile changes, shared groups, - People: `get_person` gives one person in full: profile changes, shared groups,
joins and leaves, deleted message count, the private chat. joins and leaves, deleted message count, the private chat.
- Forensics: `get_feed` lists what was deleted or edited in Telegram across - Forensics: `get_feed` lists what was deleted or edited in Telegram across
all chats, newest first, with the text before and after each edit; a bulk all chats, newest first, with the text before and after each edit; a bulk
deletion is one item with `message_count`. `get_deleted_messages` and deletion is one item with `message_count`. Its `profile` kind lists name,
username and photo changes of people and renames of chats. `get_deleted_messages` and
`get_message_versions` recover the content of one chat or one message. `get_message_versions` recover the content of one chat or one message.
- Files: `download_media` gets any message attachment (photo, video, voice, - Files: `download_media` gets any message attachment (photo, video, voice,
document, ...). It pulls the file from Telegram if the archive lacks it and document, ...). It pulls the file from Telegram if the archive lacks it and
@@ -98,7 +100,8 @@ Watched bots:
Writes: everything is read-only except `set_watch` and the share links Writes: everything is read-only except `set_watch` and the share links
`download_media` creates (visible and revocable in the web UI). `download_media` creates (visible and revocable in the web UI).
Paging: list/search tools accept `limit` and `offset`. Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns
`next_cursor` to pass back as `cursor`.
""" """
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS) mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
@@ -137,27 +140,49 @@ async def search_messages_tool(
query: str | None = None, query: str | None = None,
chat_id: int | None = None, chat_id: int | None = None,
sender_id: int | None = None, sender_id: int | None = None,
kind: ChatKind | None = None,
has_media: bool | None = None, has_media: bool | None = None,
deleted: bool | None = None,
edited: bool | None = None,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
regex: str | None = None, regex: str | None = None,
cursor: str | None = None,
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, ) -> dict[str, Any]:
) -> list[dict[str, Any]]: """Search messages by words, newest first, including deleted ones.
"""Full-text search over message text and STT transcripts."""
Words match by prefix (3+ letters) and by stem, Russian and English.
`"exact phrase"`, `-word` and `or` work as in web search. Besides the
current text it searches voice transcripts and earlier versions of edited
messages: a hit with `source="version"` matched only an old text, given in
`version_text`. Every hit carries `deleted_at` and `edited_at`.
Filters: `chat_id`, `sender_id` (peer id), `kind` (private, group,
channel), `has_media`, `deleted` / `edited` (true for only those, false to
exclude them), `date_from` / `date_to`, `regex` over the current text. With
no `query` it lists messages that pass the filters.
Paging: pass `next_cursor` from the previous result as `cursor`; it is null
on the last page.
"""
filters = SearchFilters( filters = SearchFilters(
account_id=account_id, account_id=account_id,
query=query, query=query,
chat_id=chat_id, chat_id=chat_id,
sender_id=sender_id, sender_id=sender_id,
kind=kind,
has_media=has_media, has_media=has_media,
deleted=deleted,
edited=edited,
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
regex=regex, regex=regex,
cursor=cursor,
limit=limit, limit=limit,
offset=offset,
) )
return _dump(await search_messages(await _pool(), filters)) page = await search_messages(await _pool(), filters)
return page.model_dump(mode="json", exclude={"terms"})
@mcp.tool @mcp.tool
@@ -270,14 +295,17 @@ async def get_feed(
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
cursor: str | None = None, cursor: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Deleted and edited messages across chats, newest first. """Deleted and edited messages and profile changes across chats, newest first.
`kind` picks deleted, edited or all; `since` keeps items at or after that `kind` picks deleted, edited, profile, messages (deleted and edited) or all;
time; `chat_id` or `chat_kind` (dm, group, channel) narrow it down. Edited `since` keeps items at or after that time; `chat_id` or `chat_kind` (dm,
items carry `before` and `after` texts. A deleted item with group, channel) narrow it down. Edited items carry `before` and `after`
`message_count` above 1 is a bulk deletion in one chat; read its messages texts. A deleted item with `message_count` above 1 is a bulk deletion in
with `get_deleted_messages` for that chat. Pass `next_cursor` back as one chat; read its messages with `get_deleted_messages` for that chat.
`cursor` for the next page. Profile items cover people in private chats or contacts and chats in the
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
`title` to `[old, new]`, changes a day apart or closer are merged. Pass
`next_cursor` back as `cursor` for the next page.
""" """
if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None: if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None:
msg = "cursor must be a next_cursor returned by get_feed" msg = "cursor must be a next_cursor returned by get_feed"
+2 -2
View File
@@ -4,7 +4,7 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from utils.search.models import SearchFilters, SearchHit from utils.search.models import SearchFilters, SearchPage
from utils.search.repository import search_messages from utils.search.repository import search_messages
router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute) router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute)
@@ -13,5 +13,5 @@ router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRout
@router.get("") @router.get("")
async def search( async def search(
pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()] pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()]
) -> list[SearchHit]: ) -> SearchPage:
return await search_messages(pool, filters) return await search_messages(pool, filters)
+18 -4
View File
@@ -1,3 +1,4 @@
import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from enum import StrEnum from enum import StrEnum
@@ -24,7 +25,8 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1) _MICROSECOND = timedelta(microseconds=1)
_ITEM_COLS = ( _ITEM_COLS = (
"id, kind, chat_id, chat_kind, message_id, message_date, at, since, message_count" "id, kind, chat_id, chat_kind, message_id, message_date, at, since, "
"message_count, detail"
) )
_VERSIONS = """ _VERSIONS = """
@@ -59,8 +61,10 @@ SET seen_at = greatest(feed_seen.seen_at, EXCLUDED.seen_at)
class FeedKind(StrEnum): class FeedKind(StrEnum):
ALL = "all" ALL = "all"
MESSAGES = "messages"
DELETED = "deleted" DELETED = "deleted"
EDITED = "edited" EDITED = "edited"
PROFILE = "profile"
class FeedChatKind(StrEnum): class FeedChatKind(StrEnum):
@@ -91,8 +95,11 @@ def _where(
) -> tuple[str, list[object]]: ) -> tuple[str, list[object]]:
params: list[object] = [account_id] params: list[object] = [account_id]
clauses = ["account_id = $1"] clauses = ["account_id = $1"]
if feed.kind is FeedKind.MESSAGES:
clauses.append("kind <> 'profile'")
exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
conditions = ( conditions = (
("kind = {}", None if feed.kind is FeedKind.ALL else feed.kind.value), ("kind = {}", feed.kind.value if exact else None),
("chat_id = {}", feed.chat_id), ("chat_id = {}", feed.chat_id),
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None), ("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
("at >= {}", feed.since), ("at >= {}", feed.since),
@@ -153,7 +160,11 @@ async def _build_items(
messages = await chats.get_messages_at( messages = await chats.get_messages_at(
pool, pool,
account_id, account_id,
[(row["chat_id"], row["message_id"], row["message_date"]) for row in rows], [
(row["chat_id"], row["message_id"], row["message_date"])
for row in rows
if row["kind"] != FeedKind.PROFILE
],
) )
versions = await _versions(pool, account_id, rows) versions = await _versions(pool, account_id, rows)
chat_views = { chat_views = {
@@ -192,6 +203,7 @@ async def _build_items(
after=version["text"] if version else None, after=version["text"] if version else None,
has_before=bool(version and version["has_before"]), has_before=bool(version and version["has_before"]),
edit_date=version["edit_date"] if version else None, edit_date=version["edit_date"] if version else None,
detail=json.loads(row["detail"]) if row["detail"] else None,
) )
) )
return items return items
@@ -226,7 +238,9 @@ async def unseen(pool: asyncpg.Pool, account_id: int) -> FeedUnseen:
"SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id "SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id
) )
counts = await pool.fetchrow(_UNSEEN, account_id, seen_at) counts = await pool.fetchrow(_UNSEEN, account_id, seen_at)
latest = await list_feed(pool, account_id, FeedFilter(), limit=1) latest = await list_feed(
pool, account_id, FeedFilter(kind=FeedKind.MESSAGES), limit=1
)
return FeedUnseen( return FeedUnseen(
count=counts["deleted"] + counts["edited"], count=counts["deleted"] + counts["edited"],
deleted=counts["deleted"], deleted=counts["deleted"],
+1
View File
@@ -235,6 +235,7 @@ class FeedItem(BaseModel):
after: str | None = None after: str | None = None
has_before: bool = False has_before: bool = False
edit_date: datetime | None = None edit_date: datetime | None = None
detail: dict[str, list[str | None]] | None = None
class FeedPage(BaseModel): class FeedPage(BaseModel):
+41 -4
View File
@@ -1,23 +1,48 @@
from datetime import datetime from datetime import UTC, datetime, timedelta
from typing import Literal from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel, field_validator
DEFAULT_LIMIT = 50 DEFAULT_LIMIT = 50
MAX_LIMIT = 500 MAX_LIMIT = 500
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1)
ChatKind = Literal["private", "group", "channel"]
HitSource = Literal["text", "stt", "version"]
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
return f"{(date - _EPOCH) // _MICROSECOND}_{chat_id}_{message_id}"
def decode_cursor(cursor: str) -> tuple[datetime, int, int]:
micros, chat_id, message_id = (int(part) for part in cursor.split("_"))
return _EPOCH + micros * _MICROSECOND, chat_id, message_id
class SearchFilters(BaseModel): class SearchFilters(BaseModel):
account_id: int account_id: int
query: str | None = None query: str | None = None
chat_id: int | None = None chat_id: int | None = None
sender_id: int | None = None sender_id: int | None = None
kind: ChatKind | None = None
has_media: bool | None = None has_media: bool | None = None
deleted: bool | None = None
edited: bool | None = None
date_from: datetime | None = None date_from: datetime | None = None
date_to: datetime | None = None date_to: datetime | None = None
regex: str | None = None regex: str | None = None
cursor: str | None = None
limit: int = DEFAULT_LIMIT limit: int = DEFAULT_LIMIT
offset: int = 0
@field_validator("cursor")
@classmethod
def _valid_cursor(cls, value: str | None) -> str | None:
if value is not None:
decode_cursor(value)
return value
class SearchHit(BaseModel): class SearchHit(BaseModel):
@@ -26,5 +51,17 @@ class SearchHit(BaseModel):
date: datetime date: datetime
sender_id: int | None sender_id: int | None
text: str | None text: str | None
source: Literal["text", "stt"] has_media: bool
media_kind: str | None
edited_at: datetime | None
deleted_at: datetime | None
source: HitSource
extracted_text: str | None extracted_text: str | None
version_text: str | None
version_at: datetime | None
class SearchPage(BaseModel):
hits: list[SearchHit]
next_cursor: str | None
terms: list[str]
+171 -72
View File
@@ -1,88 +1,187 @@
import asyncpg import asyncpg
from utils.search.models import MAX_LIMIT, SearchFilters, SearchHit from utils.search.models import (
MAX_LIMIT,
_TEXT_TSV = "to_tsvector('russian', coalesce(m.text, ''))" ChatKind,
_STT_TSV = "to_tsvector('russian', coalesce(md.extracted_text, ''))" SearchFilters,
_TSQUERY = "websearch_to_tsquery('russian', ${})" SearchHit,
SearchPage,
_HITS = "{cols} FROM messages m WHERE {where}" # text branch decode_cursor,
_STT_HITS = ( encode_cursor,
"{cols} FROM messages m "
"JOIN media md ON md.account_id = m.account_id "
"AND md.chat_id = m.chat_id AND md.message_id = m.message_id "
"WHERE {where}"
)
_TEXT_COLS = (
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, "
"'text' AS source, NULL::text AS extracted_text"
)
_STT_COLS = (
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, "
"'stt' AS source, md.extracted_text"
) )
_PARSE = """
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
|| regexp_replace(websearch_to_tsquery('simple', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
)::text AS tsquery,
tsvector_to_array(search_vector(q)) AS terms
FROM translate($1, 'Ёё', 'Ее') AS q
"""
def _common_conditions(filters: SearchFilters, params: list[object]) -> list[str]: _CHANNELS = """
params.append(filters.account_id) SELECT coalesce(array_agg(chat_id), '{}'::bigint[]) FROM (
conds = [f"m.account_id = ${len(params)}"] SELECT DISTINCT ON (chat_id) chat_id,
if filters.chat_id is not None: coalesce(raw->'chat'->>'type', raw->>'type') AS type
params.append(filters.chat_id) FROM chat_history
conds.append(f"m.chat_id = ${len(params)}") WHERE account_id = $1
if filters.sender_id is not None: AND coalesce(raw->'chat'->>'type', raw->>'type') IS NOT NULL
params.append(filters.sender_id) ORDER BY chat_id, ts DESC
conds.append(f"m.sender_id = ${len(params)}") ) typed WHERE type = 'ChatType.CHANNEL'
if filters.has_media is not None: """
params.append(filters.has_media)
conds.append(f"m.has_media = ${len(params)}") _KEY_ORDER = "s.date DESC, s.chat_id DESC, s.message_id DESC"
if filters.date_from is not None:
params.append(filters.date_from) _COLS = (
conds.append(f"m.date >= ${len(params)}") "s.chat_id, s.message_id, s.date, s.sender_id, s.text, s.has_media, "
if filters.date_to is not None: "s.edited_at, s.deleted_at"
params.append(filters.date_to) )
conds.append(f"m.date <= ${len(params)}")
_TEXT = (
f"SELECT {_COLS}, 0 AS rank, NULL::text AS extracted_text, " # noqa: S608
"NULL::text AS version_text, NULL::timestamptz AS version_at "
"FROM message_search s WHERE {where}"
)
_PROBE = (
"CROSS JOIN LATERAL (SELECT * FROM message_search s "
"WHERE s.chat_id = {source}.chat_id AND s.message_id = {source}.message_id "
"AND {where} LIMIT 1) s "
)
_STT = (
f"SELECT {_COLS}, 1 AS rank, md.extracted_text, NULL::text, " # noqa: S608
"NULL::timestamptz FROM media md "
f"{_PROBE.replace('{source}', 'md')}"
"WHERE md.account_id = {account} "
"AND search_vector(md.extracted_text) @@ {tsquery}"
)
_VERSION = (
"SELECT DISTINCT ON (s.date, s.chat_id, s.message_id) " # noqa: S608
f"{_COLS}, 2 AS rank, NULL::text, v.text, v.observed_at "
"FROM message_versions v "
f"{_PROBE.replace('{source}', 'v')}"
"WHERE v.account_id = {account} "
"AND search_vector(v.text) @@ {tsquery}"
)
_WITH_MEDIA = (
"SELECT h.*, md.kind AS media_kind FROM ({hits}) h "
"LEFT JOIN media md ON md.account_id = {account} "
"AND md.chat_id = h.chat_id AND md.message_id = h.message_id "
"ORDER BY h.date DESC, h.chat_id DESC, h.message_id DESC"
)
_SOURCES = ("text", "stt", "version")
class _Binder:
def __init__(self) -> None:
self.params: list[object] = []
def __call__(self, value: object) -> str:
self.params.append(value)
return f"${len(self.params)}"
def _kind(kind: ChatKind, channels: list[int], bind: _Binder) -> str:
if kind == "private":
return "s.chat_id > 0"
member = f"s.chat_id = ANY({bind(channels)}::bigint[])"
return member if kind == "channel" else f"s.chat_id < 0 AND NOT {member}"
def _conditions(
filters: SearchFilters, channels: list[int], bind: _Binder
) -> list[str]:
conds = [f"s.account_id = {bind(filters.account_id)}"]
compared = (
("s.chat_id =", filters.chat_id),
("s.sender_id =", filters.sender_id),
("s.has_media =", filters.has_media),
("s.date >=", filters.date_from),
("s.date <=", filters.date_to),
("s.text ~", filters.regex),
)
conds += [f"{left} {bind(value)}" for left, value in compared if value is not None]
flags = (("s.deleted_at", filters.deleted), ("s.edited_at", filters.edited))
conds += [
f"{column} IS {'NOT ' if flag else ''}NULL"
for column, flag in flags
if flag is not None
]
if filters.kind is not None:
conds.append(_kind(filters.kind, channels, bind))
if filters.cursor is not None:
date, chat_id, message_id = decode_cursor(filters.cursor)
conds.append(
"(s.date, s.chat_id, s.message_id) < "
f"({bind(date)}, {bind(chat_id)}, {bind(message_id)})"
)
return conds return conds
async def search_messages( def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
pool: asyncpg.Pool, filters: SearchFilters return f"({sql.format(**parts)} ORDER BY {order} LIMIT {limit})"
) -> list[SearchHit]:
query = filters.query.strip() if filters.query else None
params: list[object] = []
common = _common_conditions(filters, params)
if filters.regex is not None:
params.append(filters.regex)
common.append(f"m.text ~ ${len(params)}")
q_idx: int | None = None
if query is not None:
params.append(query)
q_idx = len(params)
text_conds = [*common] async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
if q_idx is not None: async with pool.acquire() as conn, conn.transaction():
text_conds.append(f"{_TEXT_TSV} @@ {_TSQUERY.format(q_idx)}") await conn.execute("SET LOCAL jit = off")
return await _search(conn, filters)
branches = [_HITS.format(cols=_TEXT_COLS, where=" AND ".join(text_conds))]
if q_idx is not None:
stt_conds = [*common, f"{_STT_TSV} @@ {_TSQUERY.format(q_idx)}"]
branches.append(_STT_HITS.format(cols=_STT_COLS, where=" AND ".join(stt_conds)))
params.append(min(filters.limit, MAX_LIMIT)) async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
limit_idx = len(params) query = (filters.query or "").strip()
params.append(filters.offset) terms: list[str] = []
offset_idx = len(params) tsquery = None
page = f"ORDER BY date DESC LIMIT ${limit_idx} OFFSET ${offset_idx}" if query:
parsed = await conn.fetchrow(_PARSE, query)
terms = list(parsed["terms"])
if not parsed["tsquery"]:
return SearchPage(hits=[], next_cursor=None, terms=terms)
tsquery = parsed["tsquery"]
if len(branches) == 1: channels: list[int] = []
sql = f"SELECT * FROM ({branches[0]}) hits {page}" # noqa: S608 if filters.kind in {"group", "channel"}:
channels = await conn.fetchval(_CHANNELS, filters.account_id)
bind = _Binder()
where = " AND ".join(_conditions(filters, channels, bind))
size = min(filters.limit, MAX_LIMIT)
limit = bind(size + 1)
account = bind(filters.account_id)
if tsquery is None:
sql = _branch(_TEXT, limit, where=where)
else: else:
union = " UNION ALL ".join(f"({b})" for b in branches) match = f"{bind(tsquery)}::tsquery"
dedup = ( branches = (
"SELECT DISTINCT ON (chat_id, message_id) * " # noqa: S608 _branch(_TEXT, limit, where=f"{where} AND s.tsv @@ {match}"),
f"FROM ({union}) hits ORDER BY chat_id, message_id, source DESC" _branch(_STT, limit, where=where, account=account, tsquery=match),
_branch(
_VERSION,
limit,
f"{_KEY_ORDER}, v.observed_at DESC",
where=where,
account=account,
tsquery=match,
),
)
sql = (
"SELECT DISTINCT ON (date, chat_id, message_id) * FROM (" # noqa: S608
f"{' UNION ALL '.join(branches)}) s "
f"ORDER BY {_KEY_ORDER}, rank LIMIT {limit}"
) )
sql = f"SELECT * FROM ({dedup}) d {page}" # noqa: S608
rows = await pool.fetch(sql, *params) rows = await conn.fetch(_WITH_MEDIA.format(hits=sql, account=account), *bind.params)
return [SearchHit(**dict(row)) for row in rows] hits = [
SearchHit(**{**dict(row), "source": _SOURCES[row["rank"]]})
for row in rows[:size]
]
next_cursor = None
if len(rows) > size:
last = hits[-1]
next_cursor = encode_cursor(last.date, last.chat_id, last.message_id)
return SearchPage(hits=hits, next_cursor=next_cursor, terms=terms)
+4 -9
View File
@@ -41,7 +41,8 @@ import type {
ReadInfo, ReadInfo,
ResponseStats, ResponseStats,
ScopeTarget, ScopeTarget,
SearchHit, SearchPage,
SearchQuery,
StorageChat, StorageChat,
StorageFile, StorageFile,
StorageGroupName, StorageGroupName,
@@ -555,14 +556,8 @@ export function getMessageMedia(
}); });
} }
export function searchMessages( export function searchMessages(query: SearchQuery): Promise<SearchPage> {
query: string, return request<SearchPage>("/search", { account: true, query: { ...query } });
options: Page & { chat_id?: number } = {}
): Promise<SearchHit[]> {
return request<SearchHit[]>("/search", {
account: true,
query: { query, ...options },
});
} }
export function fetchMedia( export function fetchMedia(
+31 -2
View File
@@ -7,7 +7,9 @@ export type PolicyScopeType =
| "default_channel" | "default_channel"
| "folder" | "folder"
| "chat"; | "chat";
export type SearchSource = "text" | "stt"; export type SearchSource = "text" | "stt" | "version";
export type SearchChatKind = "private" | "group" | "channel";
export type JobStatus = export type JobStatus =
| "pending" | "pending"
| "running" | "running"
@@ -222,7 +224,7 @@ export interface MessageVersion {
text: string | null; text: string | null;
} }
export type FeedKind = "all" | "deleted" | "edited"; export type FeedKind = "all" | "deleted" | "edited" | "profile";
export type FeedChatKind = "dm" | "group" | "channel"; export type FeedChatKind = "dm" | "group" | "channel";
export interface FeedChat { export interface FeedChat {
@@ -237,6 +239,7 @@ export interface FeedItem {
at: string; at: string;
before: string | null; before: string | null;
chat: FeedChat; chat: FeedChat;
detail: Record<string, [string | null, string | null]> | null;
edit_date: string | null; edit_date: string | null;
has_before: boolean; has_before: boolean;
id: number; id: number;
@@ -280,11 +283,37 @@ export interface MediaVersion {
export interface SearchHit { export interface SearchHit {
chat_id: number; chat_id: number;
date: string; date: string;
deleted_at: string | null;
edited_at: string | null;
extracted_text: string | null; extracted_text: string | null;
has_media: boolean;
media_kind: string | null;
message_id: number; message_id: number;
sender_id: number | null; sender_id: number | null;
source: SearchSource; source: SearchSource;
text: string | null; text: string | null;
version_at: string | null;
version_text: string | null;
}
export interface SearchPage {
hits: SearchHit[];
next_cursor: string | null;
terms: string[];
}
export interface SearchQuery {
chat_id?: number;
cursor?: string;
date_from?: string;
date_to?: string;
deleted?: boolean;
edited?: boolean;
has_media?: boolean;
kind?: SearchChatKind;
limit?: number;
query?: string;
sender_id?: number;
} }
export interface MediaView { export interface MediaView {
@@ -18,11 +18,14 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WebPage from "#lib/components/WebPage.svelte"; import WebPage from "#lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js"; import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { appear } from "#lib/transitions/appear.js"; import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation";
interface Props { interface Props {
animate: boolean; animate: boolean;
@@ -59,27 +62,47 @@
); );
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const senderChat = $derived(
message.sender_id !== null && message.sender_id < 0
? message.sender_id
: null
);
const sender = $derived( const sender = $derived(
message.sender_id === null ? undefined : peers.get(message.sender_id) message.sender_id === null || senderChat !== null
? undefined
: peers.get(message.sender_id)
); );
const senderName = $derived.by(() => { const senderName = $derived.by(() => {
if (own) { if (own) {
return accounts.selected ? accountName(accounts.selected) : "You"; return accounts.selected ? accountName(accounts.selected) : t("chat.you");
}
if (senderChat !== null) {
return chats.byId(senderChat)?.title ?? "";
} }
if (sender) { if (sender) {
return peerName(sender); return peerName(sender);
} }
return message.sender_id === null ? "" : String(message.sender_id); return message.sender_id === null ? "" : String(message.sender_id);
}); });
$effect(() => {
if (senderChat !== null) {
chats.ensure(senderChat);
}
});
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0)); const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
const showName = $derived(isGroupChat && !own && firstInGroup); const showName = $derived(isGroupChat && !own && firstInGroup);
const avatarId = $derived(own ? ownId : message.sender_id); const avatarId = $derived(own ? ownId : message.sender_id);
const avatarHas = $derived( const avatarHas = $derived.by(() => {
own if (own) {
? ownId !== null && peers.get(ownId)?.has_avatar return ownId !== null && Boolean(peers.get(ownId)?.has_avatar);
: (sender?.has_avatar ?? false) }
); if (senderChat !== null) {
return chats.byId(senderChat)?.has_avatar ?? false;
}
return sender?.has_avatar ?? false;
});
const hasCallbacks = $derived( const hasCallbacks = $derived(
message.inline_buttons.some((row) => message.inline_buttons.some((row) =>
@@ -116,14 +139,18 @@
} }
async function copyText() { async function copyText() {
await navigator.clipboard.writeText(message.text ?? ""); await navigator.clipboard.writeText(message.text ?? "");
toasts.success("Текст скопирован"); toasts.success(t("thread.copiedText"));
} }
async function copyId() { async function copyId() {
await navigator.clipboard.writeText(String(message.message_id)); await navigator.clipboard.writeText(String(message.message_id));
toasts.success("ID скопирован"); toasts.success(t("thread.copiedId"));
} }
function openSenderProfile() { function openSenderProfile() {
if (message.sender_id !== null) { if (senderChat === message.chat_id) {
ui.openPanel("profile");
} else if (senderChat !== null) {
goto(`/app/${senderChat}`);
} else if (message.sender_id !== null) {
ui.openPerson(message.chat_id, message.sender_id); ui.openPerson(message.chat_id, message.sender_id);
} }
} }
@@ -155,7 +182,10 @@
type="button" type="button"
> >
<Avatar <Avatar
avatar={{ id: avatarId, kind: "peer" }} avatar={{
id: avatarId,
kind: senderChat === null ? "peer" : "chat",
}}
colorKey={avatarId} colorKey={avatarId}
hasAvatar={avatarHas} hasAvatar={avatarHas}
name={senderName} name={senderName}
@@ -185,7 +215,7 @@
{#if deleted} {#if deleted}
<span class="deleted-tag"> <span class="deleted-tag">
<Icon name="delete" size="0.875rem" /> <Icon name="delete" size="0.875rem" />
deleted {t("thread.deleted")}
</span> </span>
{/if} {/if}
{#if message.poll} {#if message.poll}
@@ -218,7 +248,7 @@
/> />
</div> </div>
{:else if !(message.has_media || deleted || special)} {:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div> <div class="text empty">{t("feed.noText")}</div>
{/if} {/if}
{#if message.web_page} {#if message.web_page}
<WebPage {message} {onmedia} {own} /> <WebPage {message} {onmedia} {own} />
@@ -233,8 +263,7 @@
{#if linkCount > 0} {#if linkCount > 0}
<button class="detail-chip" onclick={openLinks} type="button"> <button class="detail-chip" onclick={openLinks} type="button">
<Icon name="link" size="0.875rem" /> <Icon name="link" size="0.875rem" />
{linkCount} {t("thread.links", { count: linkCount })}
{linkCount === 1 ? "ссылка" : "ссылки"}
</button> </button>
{/if} {/if}
<MessageMeta {message} {onversions} {own} /> <MessageMeta {message} {onversions} {own} />
@@ -244,7 +273,7 @@
{#if hasCallbacks} {#if hasCallbacks}
<button class="detail-chip" onclick={openCallbacks} type="button"> <button class="detail-chip" onclick={openCallbacks} type="button">
<Icon name="bot-command" size="0.875rem" /> <Icon name="bot-command" size="0.875rem" />
Callback-данные {t("panel.callbacks")}
</button> </button>
{/if} {/if}
{#if lastInGroup} {#if lastInGroup}
@@ -263,42 +292,44 @@
<MessageReadItem {message} /> <MessageReadItem {message} />
{/if} {/if}
<ContextMenuItem icon="note" onselect={openAnnotations} <ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem >{t("panel.annotations")}</ContextMenuItem
> >
{#if message.reactions.length} {#if message.reactions.length}
<ContextMenuItem icon="heart" onselect={openReactions} <ContextMenuItem icon="heart" onselect={openReactions}
>Реакции</ContextMenuItem >{t("panel.reactions")}</ContextMenuItem
> >
{/if} {/if}
{#if linkCount > 0} {#if linkCount > 0}
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem> <ContextMenuItem icon="link" onselect={openLinks}
>{t("panel.links")}</ContextMenuItem
>
{/if} {/if}
{#if hasCallbacks} {#if hasCallbacks}
<ContextMenuItem icon="bot-command" onselect={openCallbacks} <ContextMenuItem icon="bot-command" onselect={openCallbacks}
>Callback-данные</ContextMenuItem >{t("panel.callbacks")}</ContextMenuItem
> >
{/if} {/if}
{#if message.edited_at} {#if message.edited_at}
<ContextMenuItem icon="edit" onselect={onversions} <ContextMenuItem icon="edit" onselect={onversions}
>Версии</ContextMenuItem >{t("panel.versions")}</ContextMenuItem
> >
{/if} {/if}
{#if (message.reply?.message_id ?? null) !== null} {#if (message.reply?.message_id ?? null) !== null}
<ContextMenuItem icon="reply" onselect={jumpToReply} <ContextMenuItem icon="reply" onselect={jumpToReply}
>Перейти к ответу</ContextMenuItem >{t("thread.toReply")}</ContextMenuItem
> >
{/if} {/if}
{#if hasText} {#if hasText}
<ContextMenuItem icon="copy" onselect={copyText} <ContextMenuItem icon="copy" onselect={copyText}
>Копировать текст</ContextMenuItem >{t("thread.copyText")}</ContextMenuItem
> >
{/if} {/if}
<ContextMenuItem icon="hashtag" onselect={copyId} <ContextMenuItem icon="hashtag" onselect={copyId}
>Копировать ID</ContextMenuItem >{t("thread.copyId")}</ContextMenuItem
> >
{#if isGroupChat && !own && message.sender_id !== null} {#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile} <ContextMenuItem icon="info" onselect={openSenderProfile}
>Профиль автора</ContextMenuItem >{t("thread.senderProfile")}</ContextMenuItem
> >
{/if} {/if}
{/snippet} {/snippet}
@@ -14,6 +14,8 @@
import { formatDay } from "#lib/format/datetime.js"; import { formatDay } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -54,7 +56,11 @@
let highlightTimer: ReturnType<typeof setTimeout> | null = null; let highlightTimer: ReturnType<typeof setTimeout> | null = null;
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const isGroupChat = $derived(chatId < 0); const isChannel = $derived(
Boolean(chats.byId(chatId)?.is_broadcast) ||
discover.get(chatId)?.kind === "channel"
);
const isGroupChat = $derived(chatId < 0 && !isChannel);
interface Row { interface Row {
dayKey: string; dayKey: string;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { FeedKind } from "#lib/api/types.js"; import type { FeedItem, FeedKind } from "#lib/api/types.js";
import FeedFilters from "#lib/components/feed/FeedFilters.svelte"; import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
import FeedList from "#lib/components/feed/FeedList.svelte"; import FeedList from "#lib/components/feed/FeedList.svelte";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -16,10 +16,14 @@
kind = next; kind = next;
} }
function open(messageId: number) { function open(item: FeedItem, messageId: number) {
if (chatId === null) { if (chatId === null) {
return; return;
} }
if (item.kind === "profile" && !messageId) {
ui.openPanel("profile");
return;
}
ui.requestJump(chatId, messageId); ui.requestJump(chatId, messageId);
if (isMobile()) { if (isMobile()) {
ui.closePanel(); ui.closePanel();
@@ -28,8 +32,19 @@
</script> </script>
{#if chatId !== null} {#if chatId !== null}
<div class="filters">
<FeedFilters {kind} onkind={pick} /> <FeedFilters {kind} onkind={pick} />
</div>
{#key chatId} {#key chatId}
<FeedList {chatId} {kind} onopen={(_, messageId) => open(messageId)} /> <FeedList {chatId} {kind} onopen={open} />
{/key} {/key}
{/if} {/if}
<style lang="scss">
.filters {
position: sticky;
z-index: 1;
top: 0;
background-color: var(--color-background);
}
</style>
@@ -2,6 +2,7 @@
import { ripple } from "#lib/actions/ripple.js"; import { ripple } from "#lib/actions/ripple.js";
import type { FeedItem, MessageView } from "#lib/api/types.js"; import type { FeedItem, MessageView } from "#lib/api/types.js";
import FeedBulk from "#lib/components/feed/FeedBulk.svelte"; import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
import FeedPhoto from "#lib/components/feed/FeedPhoto.svelte";
import FeedText from "#lib/components/feed/FeedText.svelte"; import FeedText from "#lib/components/feed/FeedText.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
@@ -20,6 +21,14 @@
let { item, onopen, inChat = false }: Props = $props(); let { item, onopen, inChat = false }: Props = $props();
const FIELD_KEYS = {
name: "feed.field.name",
photo: "feed.field.photo",
title: "feed.field.title",
username: "feed.field.username",
} as const;
const FIELD_ORDER = ["name", "title", "username", "photo"] as const;
let expanded = $state(false); let expanded = $state(false);
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
@@ -67,8 +76,14 @@
? t("chat.deletedAccount") ? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: item.chat.chat_id })) : t("chat.fallbackTitle", { id: item.chat.chat_id }))
); );
const profile = $derived(item.kind === "profile");
const fields = $derived(
FIELD_ORDER.filter((field) => item.detail?.[field]).map(
(field) => [field, item.detail?.[field] ?? [null, null]] as const
)
);
const title = $derived.by(() => { const title = $derived.by(() => {
if (!inChat) { if (!inChat || profile) {
return chatTitle; return chatTitle;
} }
if (bulk) { if (bulk) {
@@ -77,7 +92,7 @@
return senderName(item.sender_id, item.sender_name); return senderName(item.sender_id, item.sender_name);
}); });
const avatar = $derived.by(() => { const avatar = $derived.by(() => {
if (!inChat) { if (!inChat || profile) {
return { return {
has: item.chat.has_avatar, has: item.chat.has_avatar,
id: item.chat.chat_id, id: item.chat.chat_id,
@@ -92,7 +107,7 @@
}; };
}); });
const prefix = $derived.by(() => { const prefix = $derived.by(() => {
if (inChat) { if (inChat || profile) {
return ""; return "";
} }
if (item.outgoing) { if (item.outgoing) {
@@ -107,6 +122,13 @@
: null : null
); );
function shown(field: string, value: string | null): string {
if (!value) {
return t("feed.noText");
}
return field === "username" ? `@${value}` : value;
}
function open() { function open() {
if (bulk) { if (bulk) {
expanded = !expanded; expanded = !expanded;
@@ -142,7 +164,27 @@
{formatListDate(item.at)} {formatListDate(item.at)}
</time> </time>
</span> </span>
{#if bulk} {#if profile}
{#each fields as [field, pair] (field)}
<span class="change now">
<span class="tag">{t(FIELD_KEYS[field])}</span>
{#if field === "photo"}
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[0]} />
<span class="arrow">→</span>
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[1]} />
{:else}
<span class="was">{shown(field, pair[0])}</span>
<span class="arrow">→</span>
{shown(field, pair[1])}
{/if}
</span>
{/each}
{#if item.message_count > fields.length}
<span class="count">
{t("feed.profileCount", { count: item.message_count })}
</span>
{/if}
{:else if bulk}
<span class="mark"> <span class="mark">
<Icon name="delete" size="0.875rem" /> <Icon name="delete" size="0.875rem" />
{inChat {inChat
@@ -360,4 +402,18 @@
.missing { .missing {
font-style: italic; font-style: italic;
} }
.was {
color: var(--color-text-secondary);
}
.arrow {
margin-inline: 0.25rem;
color: var(--color-text-secondary);
}
.count {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style> </style>
@@ -16,6 +16,7 @@
all: "feed.filter.all", all: "feed.filter.all",
deleted: "feed.filter.deleted", deleted: "feed.filter.deleted",
edited: "feed.filter.edited", edited: "feed.filter.edited",
profile: "feed.filter.profile",
} as const; } as const;
const CHAT_KEYS = { const CHAT_KEYS = {
@@ -69,7 +70,7 @@
.group { .group {
display: inline-flex; display: inline-flex;
flex-wrap: nowrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem;
} }
@@ -25,6 +25,7 @@
all: "feed.empty.all", all: "feed.empty.all",
deleted: "feed.empty.deleted", deleted: "feed.empty.deleted",
edited: "feed.empty.edited", edited: "feed.empty.edited",
profile: "feed.empty.profile",
} as const; } as const;
let items = $state<FeedItem[]>([]); let items = $state<FeedItem[]>([]);
@@ -0,0 +1,64 @@
<script lang="ts">
import { loadAvatarVariant } from "#lib/api/avatars.js";
import Icon from "#lib/components/ui/Icon.svelte";
interface Props {
ownerId: number;
uniqueId: string | null;
}
let { ownerId, uniqueId }: Props = $props();
let url = $state<string | null>(null);
$effect(() => {
url = null;
if (uniqueId === null) {
return;
}
let active = true;
loadAvatarVariant(ownerId > 0 ? "peer" : "chat", ownerId, uniqueId).then(
(resolved) => {
if (active) {
url = resolved;
}
}
);
return () => {
active = false;
};
});
</script>
<span class="feed-photo">
{#if url}
<img alt="" src={url}>
{:else}
<Icon name="photo" size="0.875rem" />
{/if}
</span>
<style lang="scss">
.feed-photo {
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
vertical-align: middle;
color: var(--color-white);
background-color: var(--color-gray);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -11,6 +11,7 @@
feed, feed,
messageHref, messageHref,
} from "#lib/stores/feed.svelte.js"; } from "#lib/stores/feed.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -20,8 +21,11 @@
let { filter }: Props = $props(); let { filter }: Props = $props();
function open(item: FeedItem, messageId: number) { async function open(item: FeedItem, messageId: number) {
goto(messageHref(page.url, item.chat.chat_id, messageId)); await goto(messageHref(page.url, item.chat.chat_id, messageId));
if (item.kind === "profile" && !messageId) {
ui.openPanel("profile");
}
} }
onMount(() => { onMount(() => {
@@ -1,69 +1,58 @@
<script lang="ts"> <script lang="ts">
import { searchMessages } from "#lib/api/endpoints.js"; import { untrack } from "svelte";
import type { SearchHit } from "#lib/api/types.js"; import { visible } from "#lib/actions/visible.js";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.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"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { createHitPager, search } from "#lib/stores/search.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { isMobile } from "#lib/viewport.js";
import { page } from "$app/state"; import { page } from "$app/state";
const DEBOUNCE_MS = 250; const DEBOUNCE_MS = 250;
const pager = createHitPager();
let query = $state("");
let typing = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const chatId = $derived( const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
); );
let query = $state(""); function run() {
let hits = $state<SearchHit[]>([]); typing = false;
let loading = $state(false); const value = query.trim();
let timer: ReturnType<typeof setTimeout> | null = null; pager.run(
let seq = 0; value && chatId !== null ? { chat_id: chatId, query: value } : null
);
}
async function run(value: string, id: number) { $effect(() => {
seq += 1; if (chatId !== null) {
const current = seq; untrack(run);
loading = true;
try {
const result = await searchMessages(value, { chat_id: id });
if (current === seq) {
hits = result;
}
} catch {
if (current === seq) {
hits = [];
}
} finally {
if (current === seq) {
loading = false;
}
}
} }
});
function onInput(event: Event) { function onInput(event: Event) {
query = (event.currentTarget as HTMLInputElement).value; query = (event.currentTarget as HTMLInputElement).value;
const value = query.trim(); typing = true;
if (timer) { if (timer !== null) {
clearTimeout(timer); clearTimeout(timer);
} }
if (value.length === 0 || chatId === null) { timer = setTimeout(run, DEBOUNCE_MS);
seq += 1;
hits = [];
loading = false;
return;
}
const id = chatId;
loading = true;
timer = setTimeout(() => {
run(value, id).catch(() => undefined);
}, DEBOUNCE_MS);
} }
function openHit(messageId: number) { function openHit(messageId: number) {
if (chatId !== null) { if (chatId === null) {
return;
}
ui.requestJump(chatId, messageId); ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`); if (isMobile()) {
ui.closePanel();
} }
} }
</script> </script>
@@ -72,21 +61,43 @@
<div class="search-input"> <div class="search-input">
<Icon class="chat-search-icon" name="search" size="1.25rem" /> <Icon class="chat-search-icon" name="search" size="1.25rem" />
<input <input
aria-label={t("search.inChat")}
oninput={onInput} oninput={onInput}
placeholder="Поиск в чате" placeholder={t("search.inChat")}
type="text" type="search"
value={query} value={query}
> >
</div> </div>
<div class="results custom-scroll"> <div class="results custom-scroll">
{#if loading && hits.length === 0} {#if pager.hits.length > 0}
<div class="loading"><Spinner /></div> {#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
{:else if hits.length > 0} <SearchMessageItem
{#each hits as hit (`${hit.chat_id}:${hit.message_id}`)} {hit}
<SearchMessageItem {hit} onclick={() => openHit(hit.message_id)} /> href={search.hitHref(hit.chat_id, hit.message_id)}
inChat
onclick={() => openHit(hit.message_id)}
terms={pager.terms}
/>
{/each} {/each}
{:else if query.trim()} {#if pager.hasMore}
<EmptyState title="Ничего не найдено" /> {#key pager.hits.length}
<div class="sentinel" use:visible={() => pager.more()}></div>
{/key}
{/if}
{#if pager.appending}
<div class="loading"><Spinner /></div>
{/if}
{:else if pager.loading}
<div class="loading"><Spinner /></div>
{:else if pager.failed}
<div class="failed">
<p>{t("search.failed")}</p>
<Button onclick={() => pager.retry()} variant="text"
>{t("search.retry")}</Button
>
</div>
{:else if query.trim() && !typing}
<EmptyState title={t("search.nothing")} />
{/if} {/if}
</div> </div>
</div> </div>
@@ -135,6 +146,10 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
&::-webkit-search-cancel-button {
display: none;
}
&:focus { &:focus {
outline: none; outline: none;
} }
@@ -146,9 +161,27 @@
padding: 0.25rem 0.4375rem; padding: 0.25rem 0.4375rem;
} }
.sentinel {
height: 1px;
}
.loading { .loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 2rem 0; padding: 1.5rem 0;
}
.failed {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
p {
margin: 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
} }
</style> </style>
@@ -2,6 +2,7 @@
import { ripple } from "#lib/actions/ripple.js"; import { ripple } from "#lib/actions/ripple.js";
import type { DiscoverItem } from "#lib/api/types.js"; import type { DiscoverItem } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
item: DiscoverItem; item: DiscoverItem;
@@ -11,29 +12,29 @@
let { item, onclick }: Props = $props(); let { item, onclick }: Props = $props();
const title = $derived( const title = $derived(
item.title ?? (item.username ? `@${item.username}` : `Чат ${item.chat_id}`) item.title ??
(item.username
? `@${item.username}`
: t("search.chatTitle", { id: item.chat_id }))
); );
const avatarKind = $derived(item.chat_id > 0 ? "peer" : "chat"); const avatarKind = $derived(item.chat_id > 0 ? "peer" : "chat");
const badge = $derived.by(() => { const badge = $derived.by(() => {
if (item.is_bot) { if (item.is_bot) {
return "Бот"; return t("search.badgeBot");
} }
if (item.kind === "channel") { if (item.kind === "channel") {
return "Канал"; return t("search.badgeChannel");
} }
if (item.kind === "group") { if (item.kind === "group") {
return "Группа"; return t("search.badgeGroup");
} }
return item.is_contact ? "Контакт" : "Пользователь"; return t(item.is_contact ? "search.badgeContact" : "search.badgeUser");
}); });
const status = $derived.by(() => { const status = $derived.by(() => {
if (item.message_count > 0) { if (item.message_count > 0) {
return `${item.message_count} сообщений`; return t("search.messageCount", { count: item.message_count });
} }
if (item.tracked) { return t(item.tracked ? "search.tracked" : "search.notArchived");
return "Отслеживается";
}
return "Нет в архиве";
}); });
</script> </script>
@@ -0,0 +1,202 @@
<script lang="ts">
import { Popover } from "bits-ui";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatDay } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { search } from "#lib/stores/search.svelte.js";
const PRESETS: { days: number; label: MessageKey }[] = [
{ days: 0, label: "search.today" },
{ days: 7, label: "search.week" },
{ days: 30, label: "search.month" },
{ days: 365, label: "search.year" },
];
let open = $state(false);
const since = $derived(search.filters.since);
const until = $derived(search.filters.until);
const today = $derived(localDay(new Date()));
function localDay(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, "0");
const dayOfMonth = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${dayOfMonth}`;
}
function daysAgo(days: number): string {
const date = new Date();
date.setDate(date.getDate() - days);
return localDay(date);
}
function shown(value: string): string {
return formatDay(`${value}T12:00:00`);
}
const preset = $derived(
until === null
? PRESETS.find((item) => daysAgo(item.days) === since)
: undefined
);
const label = $derived.by(() => {
if (preset) {
return t(preset.label);
}
if (since && until) {
return since === until
? shown(since)
: `${shown(since)} – ${shown(until)}`;
}
if (since) {
return t("search.periodSince", { date: shown(since) });
}
if (until) {
return t("search.periodUntil", { date: shown(until) });
}
return t("search.period");
});
function apply(from: string | null, to: string | null) {
search.setPeriod(from, to);
}
function choose(days: number | null) {
apply(days === null ? null : daysAgo(days), null);
open = false;
}
function pickSince(event: Event) {
apply((event.currentTarget as HTMLInputElement).value || null, until);
}
function pickUntil(event: Event) {
apply(since, (event.currentTarget as HTMLInputElement).value || null);
}
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({
props,
})}
<button
{...props}
class="search-chip"
type="button"
class:on={since !== null || until !== null}
>
<span class="chip-label">{label}</span>
<Icon name="down" size="0.875rem" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
class="bg-menu-content search-popover"
sideOffset={6}
>
{#if since !== null || until !== null}
<button class="bg-menu-item" onclick={() => choose(null)} type="button">
<span class="option">{t("search.anyTime")}</span>
</button>
{/if}
{#each PRESETS as item (item.days)}
<button
class="bg-menu-item"
onclick={() => choose(item.days)}
type="button"
>
<span class="option">{t(item.label)}</span>
{#if preset === item}
<Icon class="search-menu-check" name="check" size="1.125rem" />
{/if}
</button>
{/each}
<div class="bg-menu-separator"></div>
<div class="range">
<label>
<span>{t("search.periodFrom")}</span>
<input
max={until ?? today}
onchange={pickSince}
type="date"
value={since ?? ""}
>
</label>
<label>
<span>{t("search.periodTo")}</span>
<input
max={today}
min={since ?? undefined}
onchange={pickUntil}
type="date"
value={until ?? ""}
>
</label>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<style lang="scss">
button.bg-menu-item {
border: 0;
font: inherit;
background: transparent;
}
.option {
flex: 1;
text-align: start;
}
.range {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.25rem 0.75rem 0.5rem;
}
label {
display: flex;
gap: 0.75rem;
align-items: center;
font-size: 0.9375rem;
color: var(--color-text-secondary);
span {
width: 1.5rem;
}
}
input {
flex: 1;
min-width: 0;
height: 2.25rem;
padding: 0 0.625rem;
border: 1px solid var(--color-borders-input);
border-radius: 0.75rem;
font: inherit;
font-size: 0.9375rem;
font-variant-numeric: tabular-nums;
color: var(--color-text);
color-scheme: light;
background-color: var(--color-background);
&:focus {
border-color: var(--color-primary);
outline: none;
box-shadow: inset 0 0 0 1px var(--color-primary);
}
}
:global(html.theme-dark) input {
color-scheme: dark;
}
</style>
@@ -0,0 +1,161 @@
<script lang="ts">
import { DropdownMenu } from "bits-ui";
import type { SearchChatKind } from "#lib/api/types.js";
import PeriodFilter from "#lib/components/search/PeriodFilter.svelte";
import SenderFilter from "#lib/components/search/SenderFilter.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { type SearchFlag, search } from "#lib/stores/search.svelte.js";
const FLAGS: { flag: SearchFlag; label: MessageKey }[] = [
{ flag: "deleted", label: "search.deleted" },
{ flag: "edited", label: "search.edited" },
{ flag: "media", label: "search.media" },
];
const KINDS: { kind: SearchChatKind | null; label: MessageKey }[] = [
{ kind: null, label: "search.kindAll" },
{ kind: "private", label: "search.kindPrivate" },
{ kind: "group", label: "search.kindGroup" },
{ kind: "channel", label: "search.kindChannel" },
];
const kind = $derived(search.filters.kind);
const kindLabel = $derived(
KINDS.find((item) => item.kind === kind)?.label ?? "search.kindAll"
);
</script>
<div class="search-filters">
{#each FLAGS as item (item.flag)}
<button
aria-pressed={search.filters[item.flag]}
class="search-chip"
onclick={() => search.toggle(item.flag)}
type="button"
class:on={search.filters[item.flag]}
>
{t(item.label)}
</button>
{/each}
<SenderFilter />
<PeriodFilter />
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({
props,
})}
<button
{...props}
class="search-chip"
type="button"
class:on={kind !== null}
>
{t(kindLabel)}
<Icon name="down" size="0.875rem" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="start"
class="bg-menu-content search-menu"
sideOffset={6}
>
{#each KINDS as item (item.label)}
<DropdownMenu.Item
class="bg-menu-item"
onSelect={() => search.setFilter("kind", item.kind)}
>
<span class="option">{t(item.label)}</span>
{#if item.kind === kind}
<Icon class="search-menu-check" name="check" size="1.125rem" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<style lang="scss">
.search-filters {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.375rem 0.25rem;
padding: 0.5rem 0.75rem 0.5rem;
border-bottom: 1px solid var(--color-borders);
}
:global(.search-chip) {
cursor: pointer;
display: inline-flex;
gap: 0.25rem;
align-items: center;
max-width: 100%;
height: 1.75rem;
padding: 0 0.625rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text);
white-space: nowrap;
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:not(:global(.on)):hover {
background-color: var(--color-background-secondary-accent);
}
}
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem;
border-radius: 1.125rem;
}
:global(.icon) {
color: var(--color-icon-secondary);
}
}
:global(.search-chip.on) {
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
:global(.search-chip .chip-label) {
overflow: hidden;
text-overflow: ellipsis;
}
:global(.search-menu) {
min-width: 11rem;
}
:global(.search-menu .search-menu-check) {
flex-shrink: 0;
color: var(--color-primary);
}
.option {
flex: 1;
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { search } from "#lib/stores/search.svelte.js"; import { search } from "#lib/stores/search.svelte.js";
let input = $state<HTMLInputElement | null>(null); let input = $state<HTMLInputElement | null>(null);
@@ -8,6 +9,13 @@
search.setQuery((event.currentTarget as HTMLInputElement).value); search.setQuery((event.currentTarget as HTMLInputElement).value);
} }
function onKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
search.close();
input?.blur();
}
}
function clear() { function clear() {
search.setQuery(""); search.setQuery("");
input?.focus(); input?.focus();
@@ -17,15 +25,22 @@
<div class="search-input"> <div class="search-input">
<Icon class="search-icon" name="search" size="1.25rem" /> <Icon class="search-icon" name="search" size="1.25rem" />
<input <input
aria-label={t("search.placeholder")}
onfocus={() => search.open()} onfocus={() => search.open()}
oninput={onInput} oninput={onInput}
placeholder="Поиск" onkeydown={onKeydown}
type="text" placeholder={t("search.placeholder")}
value={search.query} type="search"
value={search.text}
bind:this={input} bind:this={input}
> >
{#if search.query} {#if search.text}
<button aria-label="Очистить" class="clear" onclick={clear} type="button"> <button
aria-label={t("search.clear")}
class="clear"
onclick={clear}
type="button"
>
<Icon name="close" size="1.125rem" /> <Icon name="close" size="1.125rem" />
</button> </button>
{/if} {/if}
@@ -69,6 +84,10 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
&::-webkit-search-cancel-button {
display: none;
}
&:focus { &:focus {
outline: none; outline: none;
} }
@@ -91,5 +110,9 @@
&:hover { &:hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
&:focus-visible {
outline: 2px solid var(--color-primary);
}
} }
</style> </style>
@@ -2,81 +2,141 @@
import { ripple } from "#lib/actions/ripple.js"; import { ripple } from "#lib/actions/ripple.js";
import type { SearchHit } from "#lib/api/types.js"; import type { SearchHit } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import { formatListDate } from "#lib/format/datetime.js"; 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 { peerName } from "#lib/format/peer.js";
import { highlight } from "#lib/format/search.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
interface Props { interface Props {
hit: SearchHit; hit: SearchHit;
href: string;
inChat?: boolean;
onclick: () => void; onclick: () => void;
terms: string[];
} }
let { hit, onclick }: Props = $props(); let { hit, href, inChat = false, onclick, terms }: Props = $props();
const chat = $derived(chats.byId(hit.chat_id)); const chat = $derived(chats.byId(hit.chat_id));
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
$effect(() => { $effect(() => {
chats.ensure(hit.chat_id); chats.ensure(hit.chat_id);
const ids: number[] = []; const ids = [hit.chat_id, hit.sender_id].filter(
if (hit.chat_id > 0) { (id): id is number => id !== null && id > 0
ids.push(hit.chat_id); );
}
if (hit.sender_id !== null) {
ids.push(hit.sender_id);
}
if (ids.length > 0) { if (ids.length > 0) {
peers.ensure(ids); peers.ensure(ids);
} }
}); });
const title = $derived.by(() => { const chatTitle = $derived.by(() => {
if (chat?.title) { if (chat?.title) {
return chat.title; return chat.title;
} }
if (hit.chat_id > 0) { const peer = hit.chat_id > 0 ? peers.get(hit.chat_id) : undefined;
return peerName(peers.get(hit.chat_id) ?? null) || `Chat ${hit.chat_id}`; return (
} (peer && peerName(peer)) || t("search.chatTitle", { id: hit.chat_id })
return `Chat ${hit.chat_id}`; );
}); });
const sender = $derived.by(() => { const senderName = $derived.by(() => {
if (hit.sender_id === null) { if (hit.sender_id === null) {
return ""; return "";
} }
if (hit.sender_id === ownId) { if (own) {
return "Вы"; return t("search.you");
} }
return peerName(peers.get(hit.sender_id) ?? null); return peerName(peers.get(hit.sender_id) ?? null);
}); });
const snippet = $derived(hit.text ?? hit.extracted_text ?? ""); const avatarId = $derived(
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat"); inChat ? (hit.sender_id ?? hit.chat_id) : hit.chat_id
);
const title = $derived(inChat ? senderName || chatTitle : chatTitle);
const prefix = $derived(
inChat || !senderName || hit.chat_id === hit.sender_id ? "" : senderName
);
const avatarHas = $derived(
avatarId === hit.chat_id
? (chat?.has_avatar ?? false)
: (peers.get(avatarId)?.has_avatar ?? false)
);
const body = $derived.by(() => {
if (hit.source === "version") {
return hit.version_text ?? "";
}
if (hit.source === "stt") {
return hit.extracted_text ?? "";
}
return hit.text ?? "";
});
const kind = $derived(
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
);
const segments = $derived(highlight(body, terms));
</script> </script>
<button class="hit" {onclick} type="button" use:ripple> <a class="hit" {href} {onclick} use:ripple>
<Avatar <Avatar
avatar={{ id: hit.chat_id, kind: avatarKind }} avatar={{ id: avatarId, kind: avatarId > 0 ? "peer" : "chat" }}
colorKey={hit.chat_id} colorKey={avatarId}
hasAvatar={chat?.has_avatar ?? false} hasAvatar={avatarHas}
name={title} name={title}
size={3} size={inChat ? 2.5 : 3}
/> />
<div class="body"> <div class="body">
<div class="top"> <div class="top">
<span class="title">{title}</span> <span class="title">{title}</span>
<span class="date">{formatListDate(hit.date)}</span> {#if hit.deleted_at}
<span class="mark deleted" title={formatFull(hit.deleted_at)}>
<Icon name="delete" size="0.875rem" />
{t("search.markDeleted")}
</span>
{:else if hit.edited_at}
<span class="mark" title={formatFull(hit.edited_at)}>
{t("search.markEdited")}
</span>
{/if}
<span class="date" title={formatFull(hit.date)}>
{formatListDate(hit.date)}
</span>
</div> </div>
<div class="snippet"> <div class="snippet">
{#if sender} {#if prefix}
<span class="sender">{sender}: </span> <span class="sender">{prefix}:</span>
{/if} {/if}
{snippet} {#if kind}
<span class="kind">{kind}{body ? ":" : ""}</span>
{/if}
<span
>{#each segments as segment, index (index)}
{#if segment.hit}
<mark>{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}</span
>
</div> </div>
{#if hit.source === "version"}
<div
class="note"
title={hit.version_at ? formatFull(hit.version_at) : undefined}
>
<Icon name="edit" size="0.875rem" />
{t("search.version")}
</div> </div>
</button> {/if}
</div>
</a>
<style lang="scss"> <style lang="scss">
.hit { .hit {
@@ -89,21 +149,28 @@
width: 100%; width: 100%;
padding: 0.5625rem 0.5rem; padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem; border-radius: 0.625rem;
text-align: start; text-align: start;
text-decoration: none;
color: var(--color-text); color: var(--color-text);
background-color: transparent; background-color: transparent;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover); --ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover { &:hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.body { .body {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
@@ -128,24 +195,59 @@
white-space: nowrap; white-space: nowrap;
} }
.mark {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.1875rem;
align-self: center;
font-size: 0.75rem;
font-style: italic;
color: var(--color-text-secondary);
&.deleted {
font-style: normal;
color: var(--color-error);
}
}
.date { .date {
flex-shrink: 0; flex-shrink: 0;
font-size: 0.75rem; font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.snippet { .snippet {
overflow: hidden; overflow: hidden;
display: -webkit-box;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-overflow: ellipsis; text-overflow: ellipsis;
-webkit-line-clamp: 2; white-space: nowrap;
line-clamp: 2;
-webkit-box-orient: vertical;
} }
.sender { .sender,
.kind {
color: var(--color-text); color: var(--color-text);
} }
.note {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--color-text-secondary);
}
mark {
padding: 0 0.0625rem;
border-radius: 0.25rem;
color: var(--color-text);
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
}
</style> </style>
@@ -1,40 +1,58 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { visible } from "#lib/actions/visible.js";
import ChatListItem from "#lib/components/ChatListItem.svelte"; import ChatListItem from "#lib/components/ChatListItem.svelte";
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte"; import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
import SearchFilters from "#lib/components/search/SearchFilters.svelte";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.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"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { search } from "#lib/stores/search.svelte.js"; import { search } from "#lib/stores/search.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
const { messages } = search;
let scroller = $state<HTMLDivElement | null>(null);
$effect(() => {
const key = search.request;
untrack(() => {
search.sync(key);
scroller?.scrollTo({ top: 0 });
});
});
const activeChatId = $derived( const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
); );
const showChats = $derived(!search.filtered && search.chatHits.length > 0);
const hasChats = $derived(search.chatHits.length > 0); const showPeers = $derived(!search.filtered && search.peerHits.length > 0);
const hasPeers = $derived(search.peerHits.length > 0); const hasMessages = $derived(messages.hits.length > 0);
const hasMessages = $derived(search.messageHits.length > 0);
const empty = $derived( const empty = $derived(
!(search.loading || hasChats || hasPeers || hasMessages) !(
search.typing ||
messages.loading ||
messages.failed ||
showChats ||
showPeers ||
hasMessages
)
); );
function openChat(chatId: number) { function openChat(chatId: number) {
search.close(); search.reset();
goto(`/app/${chatId}`);
}
function openHit(chatId: number, messageId: number) {
ui.requestJump(chatId, messageId);
search.close();
goto(`/app/${chatId}`); goto(`/app/${chatId}`);
} }
</script> </script>
<div class="search-results custom-scroll"> <div class="search-view">
{#if hasChats} <SearchFilters />
<div class="section-label">Чаты</div> <div class="search-results custom-scroll" bind:this={scroller}>
{#if showChats}
<div class="section-label">{t("search.chats")}</div>
{#each search.chatHits as chat (chat.chat_id)} {#each search.chatHits as chat (chat.chat_id)}
<ChatListItem <ChatListItem
{chat} {chat}
@@ -44,31 +62,56 @@
{/each} {/each}
{/if} {/if}
{#if hasPeers} {#if showPeers}
<div class="section-label">Контакты и каналы</div> <div class="section-label">{t("search.peers")}</div>
{#each search.peerHits as item (item.chat_id)} {#each search.peerHits as item (item.chat_id)}
<DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} /> <DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} />
{/each} {/each}
{/if} {/if}
{#if search.loading && !hasMessages} {#if hasMessages}
<div class="loading"><Spinner /></div> <div class="section-label">{t("search.messages")}</div>
{:else if hasMessages} {#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<div class="section-label">Сообщения</div>
{#each search.messageHits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem <SearchMessageItem
{hit} {hit}
onclick={() => openHit(hit.chat_id, hit.message_id)} href={search.hitHref(hit.chat_id, hit.message_id)}
onclick={() => ui.requestJump(hit.chat_id, hit.message_id)}
terms={messages.terms}
/> />
{/each} {/each}
{#if messages.hasMore}
{#key messages.hits.length}
<div class="sentinel" use:visible={() => messages.more()}></div>
{/key}
{/if}
{#if messages.appending}
<div class="loading"><Spinner /></div>
{/if}
{:else if messages.loading}
<div class="loading"><Spinner /></div>
{:else if messages.failed}
<div class="failed">
<p>{t("search.failed")}</p>
<Button onclick={() => messages.retry()} variant="text"
>{t("search.retry")}</Button
>
</div>
{/if} {/if}
{#if empty} {#if empty}
<EmptyState title="Ничего не найдено" /> <EmptyState title={t("search.nothing")} />
{/if} {/if}
</div> </div>
</div>
<style lang="scss"> <style lang="scss">
.search-view {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.search-results { .search-results {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
@@ -82,9 +125,27 @@
color: var(--color-primary); color: var(--color-primary);
} }
.sentinel {
height: 1px;
}
.loading { .loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 2rem 0; padding: 1.5rem 0;
}
.failed {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
p {
margin: 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
} }
</style> </style>
@@ -0,0 +1,254 @@
<script lang="ts">
import { Popover } from "bits-ui";
import { discoverPeers } from "#lib/api/endpoints.js";
import type { DiscoverItem } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { search } from "#lib/stores/search.svelte.js";
interface Person {
hasAvatar: boolean;
id: number;
name: string;
}
const DEBOUNCE_MS = 250;
const recent = createChatPicker();
let open = $state(false);
let query = $state("");
let found = $state<DiscoverItem[]>([]);
let searching = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
const selected = $derived(search.filters.from);
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const typed = $derived(query.trim());
$effect(() => {
if (selected !== null && selected !== ownId) {
peers.ensure([selected]);
}
});
const selectedName = $derived.by(() => {
if (selected === null) {
return t("search.from");
}
if (selected === ownId) {
return t("search.you");
}
return peerName(peers.get(selected) ?? null) || String(selected);
});
const people = $derived.by((): Person[] => {
if (typed) {
return found
.filter((item) => item.chat_id > 0)
.map((item) => ({
hasAvatar: item.has_avatar,
id: item.chat_id,
name: item.title ?? (item.username ? `@${item.username}` : ""),
}));
}
const own: Person[] =
ownId === null
? []
: [{ hasAvatar: false, id: ownId, name: t("search.you") }];
return [
...own,
...recent.results
.filter((chat) => chat.chat_id > 0 && chat.chat_id !== ownId)
.map((chat) => ({
hasAvatar: chat.has_avatar,
id: chat.chat_id,
name: chat.title ?? String(chat.chat_id),
})),
];
});
async function find(value: string, current: number) {
try {
const items = await discoverPeers(value);
if (current === seq) {
found = items;
}
} catch {
if (current === seq) {
found = [];
}
} finally {
if (current === seq) {
searching = false;
}
}
}
function onInput(event: Event) {
query = (event.currentTarget as HTMLInputElement).value;
seq += 1;
const current = seq;
if (timer !== null) {
clearTimeout(timer);
}
const value = query.trim();
searching = value !== "";
if (!value) {
found = [];
return;
}
timer = setTimeout(() => {
find(value, current).catch(() => undefined);
}, DEBOUNCE_MS);
}
function onOpenChange(value: boolean) {
if (value) {
query = "";
found = [];
recent.search("");
}
}
function pick(id: number | null) {
search.setFilter("from", id === null ? null : String(id));
open = false;
}
</script>
<Popover.Root {onOpenChange} bind:open>
<Popover.Trigger>
{#snippet child({
props,
})}
<button
{...props}
class="search-chip"
type="button"
class:on={selected !== null}
>
<span class="chip-label">{selectedName}</span>
<Icon name="down" size="0.875rem" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
class="bg-menu-content search-popover"
sideOffset={6}
>
<input
aria-label={t("search.fromName")}
class="find"
oninput={onInput}
placeholder={t("search.fromName")}
type="search"
value={query}
>
<div class="people custom-scroll">
{#if selected !== null && !typed}
<button class="bg-menu-item" onclick={() => pick(null)} type="button">
<Icon name="user" size="1.125rem" />
<span class="name">{t("search.anyone")}</span>
</button>
{/if}
{#each people as person (person.id)}
<button
class="bg-menu-item person"
onclick={() => pick(person.id)}
type="button"
>
<Avatar
avatar={{ id: person.id, kind: "peer" }}
colorKey={person.id}
hasAvatar={person.hasAvatar}
name={person.name}
size={1.75}
/>
<span class="name">{person.name}</span>
{#if person.id === selected}
<Icon class="search-menu-check" name="check" size="1.125rem" />
{/if}
</button>
{/each}
{#if typed && !searching && people.length === 0}
<p class="empty">{t("search.nobody")}</p>
{/if}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<style lang="scss">
:global(.search-popover) {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: min(17rem, calc(100vw - 1.5rem));
}
.find {
height: 2.25rem;
margin-bottom: 0.125rem;
padding: 0 0.75rem;
border: 1px solid var(--color-borders-input);
border-radius: 0.75rem;
font-size: 0.9375rem;
color: var(--color-text);
background-color: var(--color-background);
&::placeholder {
color: var(--color-text-secondary);
}
&::-webkit-search-cancel-button {
display: none;
}
&:focus {
border-color: var(--color-primary);
outline: none;
box-shadow: inset 0 0 0 1px var(--color-primary);
}
}
.people {
overflow-y: auto;
max-height: min(20rem, 50dvh);
}
.person {
padding-block: 0.3125rem;
}
.name {
overflow: hidden;
flex: 1;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
button.bg-menu-item {
border: 0;
font: inherit;
background: transparent;
}
.empty {
margin: 0;
padding: 0.75rem;
font-size: 0.875rem;
text-align: center;
color: var(--color-text-secondary);
}
</style>
+58
View File
@@ -0,0 +1,58 @@
export interface Segment {
hit: boolean;
text: string;
}
const WORD = /[\p{L}\p{N}_]+/gu;
const SPACE = /\s/;
const PREFIX_MIN = 3;
const REACH = 20;
const LEAD = 10;
function fold(word: string): string {
return word.toLowerCase().replaceAll("ё", "е");
}
function matches(word: string, terms: string[]): boolean {
const folded = fold(word);
return terms.some((term) =>
term.length >= PREFIX_MIN ? folded.startsWith(term) : folded === term
);
}
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;
}
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)) {
continue;
}
if (match.index > last) {
segments.push({ hit: false, text: shown.slice(last, match.index) });
}
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) });
}
return segments;
}
+2
View File
@@ -5,6 +5,7 @@ import { feed } from "#lib/i18n/messages/feed.js";
import { menu } from "#lib/i18n/messages/menu.js"; import { menu } from "#lib/i18n/messages/menu.js";
import { profile } from "#lib/i18n/messages/profile.js"; import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
import { search } from "#lib/i18n/messages/search.js";
import { settings } from "#lib/i18n/messages/settings.js"; import { settings } from "#lib/i18n/messages/settings.js";
import { watches } from "#lib/i18n/messages/watches.js"; import { watches } from "#lib/i18n/messages/watches.js";
@@ -16,6 +17,7 @@ export const messages = {
...menu, ...menu,
...profile, ...profile,
...read, ...read,
...search,
...settings, ...settings,
...watches, ...watches,
}; };
+34
View File
@@ -76,13 +76,22 @@ export const feed = {
en: "No edited messages yet", en: "No edited messages yet",
ru: "Изменённых пока нет", ru: "Изменённых пока нет",
}, },
"feed.empty.profile": {
en: "No profile changes yet",
ru: "Профили пока не менялись",
},
"feed.error": { "feed.error": {
en: "Couldn't load the feed", en: "Couldn't load the feed",
ru: "Не удалось загрузить ленту", ru: "Не удалось загрузить ленту",
}, },
"feed.field.name": { en: "Name", ru: "Имя" },
"feed.field.photo": { en: "Photo", ru: "Фото" },
"feed.field.title": { en: "Title", ru: "Название" },
"feed.field.username": { en: "Username", ru: "Юзернейм" },
"feed.filter.all": { en: "All", ru: "Все" }, "feed.filter.all": { en: "All", ru: "Все" },
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" }, "feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
"feed.filter.edited": { en: "Edited", ru: "Изменённые" }, "feed.filter.edited": { en: "Edited", ru: "Изменённые" },
"feed.filter.profile": { en: "Profiles", ru: "Профили" },
"feed.lostText": { "feed.lostText": {
en: "The earlier text wasn't saved", en: "The earlier text wasn't saved",
ru: "Прежний текст не сохранился", ru: "Прежний текст не сохранился",
@@ -91,6 +100,15 @@ export const feed = {
"feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" }, "feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" },
"feed.more": { en: "Show more", ru: "Показать ещё" }, "feed.more": { en: "Show more", ru: "Показать ещё" },
"feed.noText": { en: "No text", ru: "Без текста" }, "feed.noText": { en: "No text", ru: "Без текста" },
"feed.profileCount": {
en: { one: "{count} change", other: "{count} changes" },
ru: {
few: "{count} изменения",
many: "{count} изменений",
one: "{count} изменение",
other: "{count} изменения",
},
},
"feed.retry": { en: "Retry", ru: "Повторить" }, "feed.retry": { en: "Retry", ru: "Повторить" },
"feed.title": { en: "Feed", ru: "Лента" }, "feed.title": { en: "Feed", ru: "Лента" },
"feed.unseen": { "feed.unseen": {
@@ -127,6 +145,20 @@ export const feed = {
"panel.search": { en: "Search", ru: "Поиск" }, "panel.search": { en: "Search", ru: "Поиск" },
"panel.trash": { en: "Trash", ru: "Корзина" }, "panel.trash": { en: "Trash", ru: "Корзина" },
"panel.versions": { en: "Versions", ru: "Версии" }, "panel.versions": { en: "Versions", ru: "Версии" },
"thread.copiedId": { en: "ID copied", ru: "ID скопирован" },
"thread.copiedText": { en: "Text copied", ru: "Текст скопирован" },
"thread.copyId": { en: "Copy ID", ru: "Копировать ID" },
"thread.copyText": { en: "Copy text", ru: "Копировать текст" },
"thread.deleted": { en: "deleted", ru: "удалено" },
"thread.links": {
en: { one: "{count} link", other: "{count} links" },
ru: {
few: "{count} ссылки",
many: "{count} ссылок",
one: "{count} ссылка",
other: "{count} ссылки",
},
},
"thread.loadFailed": { "thread.loadFailed": {
en: "Couldn't load messages", en: "Couldn't load messages",
ru: "Не удалось загрузить сообщения", ru: "Не удалось загрузить сообщения",
@@ -135,5 +167,7 @@ export const feed = {
en: "This message isn't in the archive", en: "This message isn't in the archive",
ru: "Этого сообщения нет в архиве", ru: "Этого сообщения нет в архиве",
}, },
"thread.senderProfile": { en: "Sender's profile", ru: "Профиль автора" },
"thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" }, "thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" },
"thread.toReply": { en: "Go to the reply", ru: "Перейти к ответу" },
} satisfies Messages; } satisfies Messages;
+61
View File
@@ -0,0 +1,61 @@
import type { Messages } from "#lib/i18n/types.js";
export const search = {
"search.anyone": { en: "Anyone", ru: "Все" },
"search.anyTime": { en: "Any time", ru: "За всё время" },
"search.badgeBot": { en: "Bot", ru: "Бот" },
"search.badgeChannel": { en: "Channel", ru: "Канал" },
"search.badgeContact": { en: "Contact", ru: "Контакт" },
"search.badgeGroup": { en: "Group", ru: "Группа" },
"search.badgeUser": { en: "User", ru: "Пользователь" },
"search.chats": { en: "Chats", ru: "Чаты" },
"search.chatTitle": { en: "Chat {id}", ru: "Чат {id}" },
"search.clear": { en: "Clear", ru: "Очистить" },
"search.deleted": { en: "Deleted", ru: "Удалённые" },
"search.edited": { en: "Edited", ru: "Изменённые" },
"search.failed": {
en: "Search did not respond",
ru: "Поиск не ответил",
},
"search.from": { en: "From", ru: "От кого" },
"search.fromName": { en: "Name or @username", ru: "Имя или @username" },
"search.inChat": { en: "Search this chat", ru: "Поиск в чате" },
"search.kindAll": { en: "All chats", ru: "Все чаты" },
"search.kindChannel": { en: "Channels", ru: "Каналы" },
"search.kindGroup": { en: "Groups", ru: "Группы" },
"search.kindPrivate": { en: "Private", ru: "Личные" },
"search.markDeleted": { en: "deleted", ru: "удалено" },
"search.markEdited": { en: "edited", ru: "изменено" },
"search.media": { en: "Media", ru: "Медиа" },
"search.messageCount": {
en: { one: "{count} message", other: "{count} messages" },
ru: {
few: "{count} сообщения",
many: "{count} сообщений",
one: "{count} сообщение",
other: "{count} сообщения",
},
},
"search.messages": { en: "Messages", ru: "Сообщения" },
"search.month": { en: "Past month", ru: "За месяц" },
"search.nobody": { en: "Nobody found", ru: "Никого не нашлось" },
"search.notArchived": { en: "Not in the archive", ru: "Нет в архиве" },
"search.nothing": { en: "Nothing found", ru: "Ничего не найдено" },
"search.peers": { en: "Contacts and channels", ru: "Контакты и каналы" },
"search.period": { en: "Period", ru: "Период" },
"search.periodFrom": { en: "From", ru: "С" },
"search.periodSince": { en: "since {date}", ru: "с {date}" },
"search.periodTo": { en: "To", ru: "По" },
"search.periodUntil": { en: "until {date}", ru: "по {date}" },
"search.placeholder": { en: "Search", ru: "Поиск" },
"search.retry": { en: "Try again", ru: "Повторить" },
"search.today": { en: "Today", ru: "Сегодня" },
"search.tracked": { en: "Tracked", ru: "Отслеживается" },
"search.version": {
en: "Found in an earlier version",
ru: "Найдено в прежней версии",
},
"search.week": { en: "Past week", ru: "За неделю" },
"search.year": { en: "Past year", ru: "За год" },
"search.you": { en: "You", ru: "Вы" },
} satisfies Messages;
+4 -2
View File
@@ -10,7 +10,7 @@ const KIND_PARAM = "feed";
const CHATS_PARAM = "chats"; const CHATS_PARAM = "chats";
const MESSAGE_PARAM = "message"; const MESSAGE_PARAM = "message";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited"]; export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"]; export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
export interface FeedFilter { export interface FeedFilter {
@@ -59,11 +59,13 @@ function href(url: UrlLike): string {
export function messageHref( export function messageHref(
url: UrlLike, url: UrlLike,
chatId: number, chatId: number,
messageId: number messageId?: number
): string { ): string {
const next = withFilter(url, feedFilterOf(url)); const next = withFilter(url, feedFilterOf(url));
next.pathname = `/app/${chatId}`; next.pathname = `/app/${chatId}`;
if (messageId) {
next.searchParams.set(MESSAGE_PARAM, String(messageId)); next.searchParams.set(MESSAGE_PARAM, String(messageId));
}
return href(next); return href(next);
} }
+285 -64
View File
@@ -3,44 +3,214 @@ import {
listChats, listChats,
searchMessages, searchMessages,
} from "#lib/api/endpoints.js"; } from "#lib/api/endpoints.js";
import type { Chat, DiscoverItem, SearchHit } from "#lib/api/types.js"; import type {
Chat,
DiscoverItem,
SearchChatKind,
SearchHit,
SearchQuery,
} from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
export type SearchFlag = "deleted" | "edited" | "media";
export interface SearchFilters {
deleted: boolean;
edited: boolean;
from: number | null;
kind: SearchChatKind | null;
media: boolean;
since: string | null;
until: string | null;
}
const DEBOUNCE_MS = 250; const DEBOUNCE_MS = 250;
const REMOTE_DEBOUNCE_MS = 700; const REMOTE_DELAY_MS = 450;
const MIN_LENGTH = 1;
const CHAT_LIMIT = 30; const CHAT_LIMIT = 30;
const PAGE_SIZE = 30;
const KINDS = new Set<string>(["private", "group", "channel"]);
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const PARAMS = [
"q",
"deleted",
"edited",
"media",
"kind",
"from",
"since",
"until",
] as const;
function createSearch() { type Param = (typeof PARAMS)[number];
let active = $state(false);
let query = $state(""); function day(value: string | null): string | null {
let messageHits = $state<SearchHit[]>([]); return value && DAY.test(value) ? value : null;
let chatHits = $state<Chat[]>([]); }
let peerResults = $state<DiscoverItem[]>([]);
function readFilters(
params: Pick<URLSearchParams, "get" | "has">
): SearchFilters {
const kind = params.get("kind");
const from = Number(params.get("from"));
return {
deleted: params.has("deleted"),
edited: params.has("edited"),
from: Number.isSafeInteger(from) && from !== 0 ? from : null,
kind: kind && KINDS.has(kind) ? (kind as SearchChatKind) : null,
media: params.has("media"),
since: day(params.get("since")),
until: day(params.get("until")),
};
}
function isFiltered(filters: SearchFilters): boolean {
return Object.values(filters).some(Boolean);
}
function toRequest(query: string, filters: SearchFilters): SearchQuery {
return {
date_from: filters.since
? new Date(`${filters.since}T00:00:00`).toISOString()
: undefined,
date_to: filters.until
? new Date(`${filters.until}T23:59:59.999`).toISOString()
: undefined,
deleted: filters.deleted || undefined,
edited: filters.edited || undefined,
has_media: filters.media || undefined,
kind: filters.kind ?? undefined,
query: query || undefined,
sender_id: filters.from ?? undefined,
};
}
export function createHitPager() {
let hits = $state<SearchHit[]>([]);
let terms = $state<string[]>([]);
let cursor = $state<string | null>(null);
let loading = $state(false); let loading = $state(false);
let timers: ReturnType<typeof setTimeout>[] = []; let appending = $state(false);
let failed = $state(false);
let base: SearchQuery | null = null;
let seq = 0; let seq = 0;
const trimmed = $derived(query.trim()); async function load(current: number, request: SearchQuery) {
failed = false;
try {
const result = await searchMessages({ ...request, limit: PAGE_SIZE });
if (current !== seq) {
return;
}
hits = request.cursor ? [...hits, ...result.hits] : result.hits;
({ terms } = result);
cursor = result.next_cursor;
} catch {
if (current === seq) {
failed = true;
}
} finally {
if (current === seq) {
loading = false;
appending = false;
}
}
}
function run(request: SearchQuery | null) {
seq += 1;
base = request;
cursor = null;
failed = false;
appending = false;
loading = request !== null;
if (request === null) {
hits = [];
terms = [];
return;
}
load(seq, request).catch(() => undefined);
}
return {
get appending() {
return appending;
},
get failed() {
return failed;
},
get hasMore() {
return cursor !== null;
},
get hits() {
return hits;
},
get loading() {
return loading;
},
more() {
if (base === null || cursor === null || loading || appending) {
return;
}
appending = true;
load(seq, { ...base, cursor }).catch(() => undefined);
},
retry() {
run(base);
},
run,
get terms() {
return terms;
},
};
}
function createSearch() {
let open = $state(false);
let draft = $state("");
let pending = $state(false);
let chatHits = $state<Chat[]>([]);
let peerResults = $state<DiscoverItem[]>([]);
let timer: ReturnType<typeof setTimeout> | null = null;
let remoteTimer: ReturnType<typeof setTimeout> | null = null;
let lastRequest = "";
let lastText = "";
let seq = 0;
const messages = createHitPager();
const params = $derived(page.url.searchParams);
const urlQuery = $derived(params.get("q") ?? "");
const filters = $derived(readFilters(params));
const filtered = $derived(isFiltered(filters));
const text = $derived(pending ? draft : urlQuery);
const active = $derived(open || text !== "" || filtered);
const request = $derived.by(() => {
const query = urlQuery.trim();
if (!(query || filtered)) {
return "";
}
return JSON.stringify([accounts.selectedId, toRequest(query, filters)]);
});
const peerHits = $derived.by(() => { const peerHits = $derived.by(() => {
const shown = new Set(chatHits.map((chat) => chat.chat_id)); const shown = new Set(chatHits.map((chat) => chat.chat_id));
return peerResults.filter((item) => !shown.has(item.chat_id)); return peerResults.filter((item) => !shown.has(item.chat_id));
}); });
async function runMessages(value: string, current: number) { function write(update: Partial<Record<Param, string | null>>) {
try { const url = new URL(page.url.href);
const hits = await searchMessages(value); url.searchParams.delete("message");
if (current === seq) { for (const [key, value] of Object.entries(update)) {
messageHits = hits; if (value) {
} url.searchParams.set(key, value);
} catch { } else {
if (current === seq) { url.searchParams.delete(key);
messageHits = [];
}
} finally {
if (current === seq) {
loading = false;
} }
} }
if (url.search === page.url.search) {
return Promise.resolve();
}
return goto(url, { replace: true, reset: false });
} }
async function runChats(value: string, current: number) { async function runChats(value: string, current: number) {
@@ -69,36 +239,41 @@ function createSearch() {
} }
} }
function clearTimers() { function runText(value: string) {
for (const timer of timers) {
clearTimeout(timer);
}
timers = [];
}
function schedule() {
clearTimers();
const value = trimmed;
seq += 1; seq += 1;
const current = seq; const current = seq;
if (value.length < MIN_LENGTH) { if (remoteTimer !== null) {
messageHits = []; clearTimeout(remoteTimer);
remoteTimer = null;
}
if (!value) {
chatHits = []; chatHits = [];
peerResults = []; peerResults = [];
loading = false;
return; return;
} }
loading = true;
timers.push(
setTimeout(() => {
runMessages(value, current).catch(() => undefined);
runChats(value, current).catch(() => undefined); runChats(value, current).catch(() => undefined);
runPeers(value, current, false).catch(() => undefined); runPeers(value, current, false).catch(() => undefined);
}, DEBOUNCE_MS), remoteTimer = setTimeout(() => {
setTimeout(() => {
runPeers(value, current, true).catch(() => undefined); runPeers(value, current, true).catch(() => undefined);
}, REMOTE_DEBOUNCE_MS) }, REMOTE_DELAY_MS);
); }
function stopTyping() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
pending = false;
}
function reset() {
open = false;
draft = "";
stopTyping();
lastRequest = "";
lastText = "";
messages.run(null);
runText("");
} }
return { return {
@@ -109,36 +284,82 @@ function createSearch() {
return chatHits; return chatHits;
}, },
close() { close() {
active = false; reset();
query = ""; const update = Object.fromEntries(PARAMS.map((key) => [key, null]));
messageHits = []; write(update).catch(() => undefined);
chatHits = [];
peerResults = [];
loading = false;
seq += 1;
clearTimers();
}, },
get loading() { get filtered() {
return loading; return filtered;
}, },
get messageHits() { get filters() {
return messageHits; return filters;
}, },
hitHref(chatId: number, messageId: number): string {
const next = new URLSearchParams({ message: String(messageId) });
for (const key of PARAMS) {
const value = params.get(key);
if (value) {
next.set(key, value);
}
}
return `/app/${chatId}?${next}`;
},
messages,
open() { open() {
active = true; open = true;
}, },
get peerHits() { get peerHits() {
return peerHits; return peerHits;
}, },
get query() { get request() {
return query; return request;
},
reset,
setFilter(key: "from" | "kind" | "since" | "until", value: string | null) {
write({ [key]: value }).catch(() => undefined);
},
setPeriod(since: string | null, until: string | null) {
write({ since, until }).catch(() => undefined);
}, },
setQuery(value: string) { setQuery(value: string) {
query = value; draft = value;
schedule(); pending = true;
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
write({ q: draft.trim() ? draft : null })
.catch(() => undefined)
.finally(() => {
if (timer === null) {
pending = false;
}
});
}, DEBOUNCE_MS);
}, },
get trimmed() { get showResults() {
return trimmed; return active && (text.trim() !== "" || filtered);
},
sync(key: string) {
if (key !== lastRequest) {
lastRequest = key;
messages.run(key ? (JSON.parse(key)[1] as SearchQuery) : null);
}
const value = urlQuery.trim();
if (value !== lastText) {
lastText = value;
runText(value);
}
},
get text() {
return text;
},
toggle(flag: SearchFlag) {
write({ [flag]: filters[flag] ? null : "1" }).catch(() => undefined);
},
get typing() {
return pending;
}, },
}; };
} }
+2 -2
View File
@@ -60,7 +60,7 @@
shownAccount = id; shownAccount = id;
untrack(() => { untrack(() => {
ui.closePanel(); ui.closePanel();
search.close(); search.reset();
}); });
}); });
@@ -112,7 +112,7 @@
{/if} {/if}
<SearchInput /> <SearchInput />
</header> </header>
{#if search.active && search.trimmed} {#if search.showResults}
<SearchResults /> <SearchResults />
{:else} {:else}
<div class="folder-tabs"> <div class="folder-tabs">