Author SHA1 Message Date
renovate Bot cd5a04a930 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to e586c1c
renovate/stability-days Updates have met minimum release age requirement
2026-10-28 04:02:03 +00:00
hh fb869e767b refactor(frontend): saved and transcription results show where they were asked, not in a toast 2026-10-28 01:56:19 +00:00
hh 58b4346166 feat(backend,frontend,mcp): saved messages take text, files, voices and archived messages, the one write to telegram 2026-10-28 01:53:48 +00:00
hh 2dd4e589c3 feat(backend,frontend,mcp): any voice or round video can be transcribed on request, media is found by its bytes 2026-10-28 01:35:56 +00:00
hh c2180e80ad feat(backend,frontend): feed gets a bots tab so private is people only, a folder's context menu opens the feed of its chats 2026-10-26 01:50:44 +00:00
renovate Bot b9e8a96774 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 331fbb1 (main) (#22)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:11:41 +00:00
hh d660079c02 feat(backend,frontend): search everywhere in buttons, links and message data, a jump from search or an alert lights up the found words and buttons 2026-10-23 22:59:29 +00:00
hh acfa726e61 fix(backend,frontend): expired story keeps its kind and archive heals stories that lost it, phone inputs no longer zoom the page 2026-10-23 16:41:42 +02:00
hh d9378056f4 fix(frontend): chat header keeps the name readable on a phone, all its actions are in its menu, menus open below the pointer and stay on screen 2026-10-19 06:19:46 +00:00
hh c307426f4e fix(frontend): context menus stay inside a narrow screen 2026-10-19 06:12:48 +00:00
hh 977dee3cb2 chore(frontend): toasts lose the success kind nobody calls anymore 2026-10-19 06:07:54 +00:00
hh bca0b34636 feat(frontend): a finished action answers on the button that was pressed instead of a toast 2026-10-19 06:07:54 +00:00
hh e228d999dd fix(frontend): account phone stored with a plus is not shown with two 2026-10-19 05:47:09 +00:00
hh aff8a9e443 fix(frontend): empty media and links tabs keep a gap under the tabs instead of sticking to them 2026-10-19 05:38:24 +00:00
hh 25364f4523 feat(frontend): forwarded header, pinned bar, folder tabs, inline buttons, edit history and deleted accounts speak russian and english 2026-10-19 05:38:24 +00:00
hh 0cd34f3037 feat(frontend): profile media, files and links tabs speak russian and english 2026-10-19 05:38:24 +00:00
hh cff493f5dd feat(frontend): analytics panel and online statuses speak russian and english with real plurals 2026-10-19 05:37:44 +00:00
hh 543e6eaa50 feat(frontend): service messages speak russian and english 2026-10-19 05:32:34 +00:00
hh 7baa32f400 chore(frontend): notes dictionary drops a string the shared ui one replaced 2026-10-19 05:26:53 +00:00
hh e977ab77a8 fix(frontend): a filter written to the url survives another url write, the chat picked with alt and arrows stays in view 2026-10-19 05:26:20 +00:00
hh 8a1723e99f fix(frontend): panel history survives a link that rewrites its own url, dictionaries reuse the thread and panel strings of the feed 2026-10-19 05:26:20 +00:00
hh c06597aba2 chore(backend): chat stats migration follows the search migration 2026-10-19 05:26:11 +00:00
hh 7f3458b3ef docs: mcp tool list matches the server 2026-10-19 05:26:11 +00:00
hh 4a32e32114 feat(frontend): online dots on private chats, deleted last message struck through, back from a chat goes through history 2026-10-19 05:26:11 +00:00
hh 9c556ff8b9 feat(frontend): notes list with tag chips and the tag in the url, from the menu and settings, capture status dot on the menu and a line in settings 2026-10-19 05:26:11 +00:00
hh 171d8d922d feat(frontend): exact time on the message time, deletion and edit times in the meta, note marker, copy link answered in the menu, media menu keeps the message items, date pill opens the calendar, capture gaps between messages 2026-10-19 05:25:54 +00:00
hh 848d2aca1a feat(frontend): right panel lives in browser history and back closes it first, message panels close on chat switch, hotkeys for search, chats, in-chat search and closing 2026-10-19 05:25:54 +00:00
hh 7f7fc1296e feat(watcher): watched bots report capture uptime like the userbot 2026-10-19 05:25:44 +00:00
hh f62c345e2b feat(backend): who of the given people is online now in one request 2026-10-19 05:25:44 +00:00
hh 9ae8b34005 feat(backend): capture status and the stretches the archive was not recording 2026-10-19 05:25:44 +00:00
hh a0e134756c feat(backend): notes list across chats with the message and chat they belong to, filter by tag, tag counts 2026-10-19 05:25:44 +00:00
hh 97f9082f5e feat(backend): chat list marks a deleted last message, history pages mark messages with a note 2026-10-19 05:25:44 +00:00
hh 90b37ec708 fix(frontend): jobs name their chats, open links tab counts only links that are really open 2026-10-19 03:53:08 +00:00
hh 4082adba25 fix(frontend): buttons keep a gap between icon and text, viewer counter stays clear of the actions on mobile 2026-10-19 03:53:08 +00:00
hh b3805123b5 fix(frontend): english storage and policy labels fit on mobile, phone placeholder no longer peeks out from under its label 2026-10-19 03:53:08 +00:00
hh 896b89f3b0 refactor(frontend): stories archives drop tile styles and imports that live in the tile 2026-10-19 03:53:08 +00:00
hh 4a5c92f0e9 feat(frontend): storage, trash, policy, stories, shares, jobs, bots, media, login and notes speak russian and english 2026-10-19 03:53:08 +00:00
hh e5a5edcaae perf(backend): search stays in tens of milliseconds at archive size for rare words, channels, deleted and transcripts 2026-10-19 03:41:49 +00:00
hh 9a6bd8a714 feat(frontend): search results show deleted, edited and earlier version marks with highlighted words, filter chips live in the url, results load on scroll 2026-10-19 03:41:44 +00:00
hh d45c96c259 feat(backend): search finds word prefixes, earlier versions and deleted messages, with filters and cursor paging 2026-10-19 03:41:39 +00:00
hh 42b4e1d7e6 fix(frontend): chips of the changes panel stay on top while the list scrolls 2026-10-19 03:27:27 +00:00
hh 309614c979 fix(frontend): channel posts show no sender id, a post sent as a chat shows its title and avatar 2026-10-19 03:25:48 +00:00
hh 98dda99c5c feat(frontend): profiles chip in the feed shows name, username, photo and title changes 2026-10-19 03:25:48 +00:00
hh 8e0f828dd4 feat(backend): feed lists name, username and photo changes of people and chat renames, merged per day 2026-10-19 03:25:43 +00:00
187 changed files with 9563 additions and 1785 deletions

No files matched your search

+8 -4
View File
@@ -1,6 +1,6 @@
# beavergram # beavergram
A read-only Telegram client that keeps everything. A userbot on your own account logs every incoming message into Postgres, including the ones later deleted or edited, along with media, reactions, presence and stories. A web client reads that archive back, and an MCP server hands it to agents. A Telegram client that keeps everything and writes nowhere but your own Saved Messages. A userbot on your own account logs every incoming message into Postgres, including the ones later deleted or edited, along with media, reactions, presence and stories. A web client reads that archive back, and an MCP server hands it to agents.
Useful on its own: a Telegram where nothing disappears. In the [beaver](https://git.kotikot.com/beaver) setup it is one of the agent's hands - the dispatcher reads chat history by the `chat_id` stored in a person's card. Useful on its own: a Telegram where nothing disappears. In the [beaver](https://git.kotikot.com/beaver) setup it is one of the agent's hands - the dispatcher reads chat history by the `chat_id` stored in a person's card.
@@ -8,10 +8,10 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
- **Messages** with edits as versions and deletions as flags, so a message removed in Telegram is still in the archive with its history. - **Messages** with edits as versions and deletions as flags, so a message removed in Telegram is still in the archive with its history.
- **Media** on disk, content-addressed, with the versions of a replaced photo. - **Media** on disk, content-addressed, with the versions of a replaced photo.
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out. - **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out: your own and the played ones by themselves, any voice or round video on request.
- **Presence** as a time series (TimescaleDB), read receipts, stories. - **Presence** as a time series (TimescaleDB), read receipts, stories.
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default. - **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
- **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client. - **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API. - **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
Nothing is ever deleted from the archive. Nothing is ever deleted from the archive.
@@ -24,7 +24,11 @@ Nothing is ever deleted from the archive.
## MCP ## MCP
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_feed`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be. Read: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`.
Act: `transcribe_message` asks Telegram for the transcript of any voice or round video, `set_watch` writes a rule, `download_media` pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. `send_to_saved` is the one thing that sends to Telegram, and only to your own Saved Messages: text, a file by URL (as a voice message when asked), or an archived message, forwarded while it lives and copied from the archive once it is gone. Send any OGG/Opus there as a voice and `transcribe_message` turns Telegram Premium into a free transcriber. There is no tool that edits, deletes or sends anywhere else.
The same over HTTP: `POST /api/media/transcribe` (`wait` seconds to hold for the text), `GET /api/media/lookup?sha256=` (where these exact bytes appear in the archive), `POST /api/saved` (multipart `account_id`, `text`, `file`, `voice`) and `POST /api/saved/forward`.
## Run ## Run
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20 FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:e586c1c65b7071aec78f02c5587c76c5011159cd76b3d6f1255f40e48e850abc
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,27 @@
"""media storage_key index for content lookup
Revision ID: b7d2e9f4a1c6
Revises: f1c4a8e2d6b3
Create Date: 2026-10-28 01:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b7d2e9f4a1c6"
down_revision: str | None = "f1c4a8e2d6b3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"CREATE INDEX ix_media_storage_key ON media (storage_key) "
"WHERE storage_key IS NOT NULL"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_media_storage_key")
@@ -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)")
@@ -0,0 +1,97 @@
"""chat stats last deleted
Revision ID: f1c4a8e2d6b3
Revises: d8f1a6c3e5b7
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "f1c4a8e2d6b3"
down_revision: str | None = "d8f1a6c3e5b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_APPLY = """
CREATE OR REPLACE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO chat_stats AS cs (account_id, chat_id, message_count,
last_date, last_message_id,
last_text, last_sender_id{column})
VALUES (NEW.account_id, NEW.chat_id, 1,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.date END,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.message_id END,
NEW.text, NEW.sender_id{value})
ON CONFLICT (account_id, chat_id) DO UPDATE SET
message_count = cs.message_count + 1,
last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_date ELSE cs.last_date END,
last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_message_id
ELSE cs.last_message_id END,
last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_text ELSE cs.last_text END,
last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_sender_id
ELSE cs.last_sender_id END{merge};
ELSE
UPDATE chat_stats
SET last_text = NEW.text, last_sender_id = NEW.sender_id{update}
WHERE account_id = NEW.account_id
AND chat_id = NEW.chat_id
AND last_message_id = NEW.message_id;
END IF;
RETURN NULL;
END;
$$
"""
_DELETED_PARTS = {
"column": ", last_deleted",
"value": ", NEW.deleted_at IS NOT NULL",
"merge": (
",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)"
" THEN EXCLUDED.last_deleted ELSE cs.last_deleted END"
),
"update": ", last_deleted = NEW.deleted_at IS NOT NULL",
"deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at",
}
_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "")
_UPDATE_TRIGGER = (
"CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages "
"FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id{deleted}) "
"EXECUTE FUNCTION chat_stats_apply()"
)
_BACKFILL = """
UPDATE chat_stats cs SET last_deleted = true
FROM messages m
WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id
AND m.message_id = cs.last_message_id AND m.date = cs.last_date
AND m.deleted_at IS NOT NULL
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false"
)
op.execute(_BACKFILL)
op.execute(_APPLY.format(**_DELETED_PARTS))
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS))
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS))
op.execute(_APPLY.format(**_PLAIN_PARTS))
op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted")
+4
View File
@@ -21,6 +21,7 @@ from api.routers import (
avatars, avatars,
backfill, backfill,
bots, bots,
capture,
chats, chats,
custom_emoji, custom_emoji,
discover, discover,
@@ -33,6 +34,7 @@ from api.routers import (
policy, policy,
presence, presence,
profile, profile,
saved,
search, search,
shares, shares,
social, social,
@@ -112,8 +114,10 @@ app.include_router(annotations.router)
app.include_router(watches.router) app.include_router(watches.router)
app.include_router(webhooks.router) app.include_router(webhooks.router)
app.include_router(shares.router) app.include_router(shares.router)
app.include_router(saved.router)
app.include_router(storage.router) app.include_router(storage.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(capture.router)
app.mount("/mcp", mcp_app) app.mount("/mcp", mcp_app)
+119 -20
View File
@@ -19,6 +19,8 @@ from api.mcp.download import (
share_media, share_media,
) )
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
from api.saved import FetchError, fetch_file, forward, send_new
from api.transcribe import request_transcript
from dependencies.container import container from dependencies.container import container
from utils.files import resolve_mime from utils.files import resolve_mime
from utils.read import ( from utils.read import (
@@ -38,14 +40,15 @@ 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
INSTRUCTIONS = """\ INSTRUCTIONS = """\
beavergram archives Telegram data (chats, messages, media, presence, stories, beavergram archives Telegram data (chats, messages, media, presence, stories,
peer history) into Postgres and exposes it read-only over these tools. peer history) into Postgres and exposes it over these tools, read-only except
for the few writes listed at the end.
Account scoping: Account scoping:
- Every tool takes `account_id`. It selects which archived Telegram account to - Every tool takes `account_id`. It selects which archived Telegram account to
@@ -72,19 +75,24 @@ 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
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
the content itself for small images and text files. `get_media` returns the content itself for small images and text files. `get_media` returns
metadata only. metadata only.
- Voice: transcripts of played voices and round videos are already in the
history ("STT Content"). For "not transcribed" ones call
`transcribe_message`.
- Monitoring: `set_watch` creates a local rule (kinds `keyword`, - Monitoring: `set_watch` creates a local rule (kinds `keyword`,
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in `new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
its description), `list_watches` lists rules, and `list_alerts` reads what its description), `list_watches` lists rules, and `list_alerts` reads what
@@ -95,10 +103,13 @@ Watched bots:
own `account_id` and its chats, messages and media read through the same own `account_id` and its chats, messages and media read through the same
tools as a user account. Nothing bot-specific to call. tools as a user account. Nothing bot-specific to call.
Writes: everything is read-only except `set_watch` and the share links Writes: everything is read-only except `set_watch`, the share links
`download_media` creates (visible and revocable in the web UI). `download_media` creates (visible and revocable in the web UI), and
`send_to_saved`, the one tool that sends to Telegram: only to the user's own
Saved Messages.
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 +148,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 +303,18 @@ 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 with people, bot, group, channel) narrow it down. Edited items carry
`message_count` above 1 is a bulk deletion in one chat; read its messages `before` and `after` texts. A deleted item with `message_count` above 1 is
with `get_deleted_messages` for that chat. Pass `next_cursor` back as a bulk deletion in one chat; read its messages with `get_deleted_messages`
`cursor` for the next page. for that chat.
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"
@@ -340,6 +377,68 @@ async def download_media(
] ]
@mcp.tool
async def transcribe_message(
account_id: int, chat_id: int, message_id: int, wait_seconds: int = 60
) -> dict[str, Any]:
"""Transcribe a voice message or round video through Telegram, any of them.
Returns the archived transcript at once when there is one; otherwise asks
Telegram (Premium transcription of the archived account) and waits up to
`wait_seconds`. `status` is `done` with `text`, `pending` (call again
later) or `failed` with `error`: `too_long`, `no_premium`, `not_voice`,
`empty`, `bot_account` or `failed`. Telegram may mark an unplayed voice as
listened for its sender.
"""
result = await request_transcript(
await _pool(), account_id, chat_id, message_id, max(wait_seconds, 0)
)
return result.model_dump(mode="json", exclude_none=True)
@mcp.tool
async def send_to_saved(
account_id: int,
text: str | None = None,
url: str | None = None,
voice: bool = False,
chat_id: int | None = None,
message_id: int | None = None,
) -> dict[str, Any]:
"""Send something to the account's own Saved Messages.
The only tool that writes to Telegram, and only to the user's own chat.
- `chat_id` + `message_id`: an archived message. It is forwarded while it
still exists in Telegram; a deleted, self-destructing or unforwardable
one goes as a copy from the archive. `mode` says which.
- `text` and/or `url`: a new message. The server fetches `url` (http or
https, up to 200 MB) and sends it as a file with `text` as the caption;
`voice=true` sends it as a voice message exactly as given, so pass
OGG/Opus. A local file can skip the URL: `POST /api/saved` (multipart
`account_id`, `text`, `voice`, `file`) with this server's bearer.
Returns `status` (`done`, `pending`, `failed` with `error`) and the
`chat_id`/`message_id` of the new message. Any audio can be transcribed
through Telegram Premium: send it with `voice=true`, then call
`transcribe_message` on the returned ids.
"""
pool = await _pool()
if chat_id is not None and message_id is not None:
result = await forward(pool, account_id, chat_id, message_id)
return result.model_dump(mode="json", exclude_none=True)
upload = None
if url is not None:
try:
upload = await fetch_file(url)
except FetchError as exc:
raise ToolError(str(exc)) from exc
if upload is None and not text:
msg = "pass text, url, or chat_id with message_id"
raise ToolError(msg)
storage = await container.get(ContentAddressedStorage)
result = await send_new(pool, storage, account_id, text, upload, voice=voice)
return result.model_dump(mode="json", exclude_none=True)
@mcp.tool @mcp.tool
async def get_callbacks( async def get_callbacks(
account_id: int, chat_id: int, message_id: int account_id: int, chat_id: int, message_id: int
+21 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.read import annotations from utils.read import annotations
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page from utils.read.models import DEFAULT_LIMIT, AnnotationView, NoteTag, NoteView, Page
router = APIRouter( router = APIRouter(
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
@@ -42,6 +42,26 @@ async def list_annotations(
) )
@router.get("/notes")
async def list_notes(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
tag: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[NoteView]:
return await annotations.list_notes(
pool, account_id, Page(limit=limit, offset=offset), tag=tag
)
@router.get("/tags")
async def list_tags(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[NoteTag]:
return await annotations.list_tags(pool, account_id)
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_annotation( async def create_annotation(
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
+7 -9
View File
@@ -7,6 +7,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from api.transcribe import TranscribeResult, request_transcript
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
@@ -35,6 +36,7 @@ class TranscribeRequest(BaseModel):
account_id: int account_id: int
chat_id: int chat_id: int
message_id: int message_id: int
wait: float = 0
class SyncDialogsRequest(BaseModel): class SyncDialogsRequest(BaseModel):
@@ -105,17 +107,13 @@ async def enqueue_fetch_media(
return EnqueueResponse(job_id=job_id) return EnqueueResponse(job_id=job_id)
@router.post("/media/transcribe", status_code=201) @router.post("/media/transcribe")
async def enqueue_transcribe( async def transcribe(
pool: FromDishka[asyncpg.Pool], body: TranscribeRequest pool: FromDishka[asyncpg.Pool], body: TranscribeRequest
) -> EnqueueResponse: ) -> TranscribeResult:
job_id = await enqueue( return await request_transcript(
pool, pool, body.account_id, body.chat_id, body.message_id, body.wait
body.account_id,
"transcribe",
{"chat_id": body.chat_id, "message_id": body.message_id},
) )
return EnqueueResponse(job_id=job_id)
@router.post("/dialogs/sync", status_code=201) @router.post("/dialogs/sync", status_code=201)
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.read import capture
from utils.read.models import CaptureGap, CaptureStatus
router = APIRouter(prefix="/api/capture", tags=["capture"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
@router.get("/status")
async def capture_status(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> CaptureStatus:
return await capture.capture_status(pool, account_id)
@router.get("/gaps")
async def capture_gaps(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> list[CaptureGap]:
return await capture.capture_gaps(
pool, account_id, date_from=date_from, date_to=date_to
)
+8 -1
View File
@@ -29,6 +29,7 @@ async def list_feed(
kind: Annotated[FeedKind, Query()] = FeedKind.ALL, kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None, chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None, chat_kind: Annotated[FeedChatKind | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None, since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None, cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT, limit: Limit = DEFAULT_LIMIT,
@@ -36,7 +37,13 @@ async def list_feed(
return await feed.list_feed( return await feed.list_feed(
pool, pool,
account_id, account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since), FeedFilter(
kind=kind,
chat_id=chat_id,
chat_kind=chat_kind,
folder_id=folder_id,
since=since,
),
limit=limit, limit=limit,
cursor=cursor, cursor=cursor,
) )
+12 -1
View File
@@ -8,17 +8,19 @@ from fastapi.responses import FileResponse
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS
from utils.files import content_disposition, media_file_name, resolve_mime from utils.files import content_disposition, media_file_name, resolve_mime
from utils.read.media import ( from utils.read.media import (
find_by_content,
get_media, get_media,
get_media_version, get_media_version,
get_media_versions, get_media_versions,
get_message_media, get_message_media,
) )
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute) router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
Download = Annotated[bool, Query()] Download = Annotated[bool, Query()]
Sha256 = Annotated[str, Query(pattern="^[0-9a-f]{64}$")]
def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]: def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]:
@@ -81,6 +83,15 @@ async def message_media(
return media return media
@router.get("/lookup")
async def lookup_media(
pool: FromDishka[asyncpg.Pool],
sha256: Sha256,
account_id: Annotated[int | None, Query()] = None,
) -> list[MediaPlaceView]:
return await find_by_content(pool, sha256, account_id)
@router.get("/{media_id}") @router.get("/{media_id}")
async def serve_media( async def serve_media(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
+11
View File
@@ -9,6 +9,7 @@ from api.timezone import checked_tz
from utils.read import presence, presence_stats from utils.read import presence, presence_stats
from utils.read.models import ( from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
MAX_LIMIT,
Page, Page,
PresenceHourly, PresenceHourly,
PresenceSample, PresenceSample,
@@ -54,6 +55,16 @@ async def current_presence(
return await presence.current_presence(pool, account_id, peer_id) return await presence.current_presence(pool, account_id, peer_id)
@router.get("/online")
async def online_now(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_ids: Annotated[str, Query()],
) -> list[PresenceSample]:
parsed = [int(part) for part in peer_ids.split(",") if part.strip()]
return await presence.online_now(pool, account_id, parsed[:MAX_LIMIT])
@router.get("/hourly") @router.get("/hourly")
async def presence_hourly( async def presence_hourly(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
+44
View File
@@ -0,0 +1,44 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from api.saved import MAX_UPLOAD_BYTES, SavedResult, forward, send_new
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/saved", tags=["saved"], route_class=DishkaRoute)
class ForwardRequest(BaseModel):
account_id: int
chat_id: int
message_id: int
@router.post("")
async def send_to_saved(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: Annotated[int, Form()],
text: Annotated[str | None, Form()] = None,
voice: Annotated[bool, Form()] = False,
file: Annotated[UploadFile | None, File()] = None,
) -> SavedResult:
upload = None
if file is not None:
if file.size is not None and file.size > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "file too large")
data = await file.read()
upload = (data, file.filename or "file", file.content_type)
if upload is None and not text:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "text or file is required")
return await send_new(pool, storage, account_id, text, upload, voice=voice)
@router.post("/forward")
async def forward_to_saved(
pool: FromDishka[asyncpg.Pool], body: ForwardRequest
) -> SavedResult:
return await forward(pool, body.account_id, body.chat_id, body.message_id)
+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)
+19 -2
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.files import content_disposition, story_file_name from utils.files import content_disposition, sniff_media_kind, story_file_name
from utils.read import peers from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"} _STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
async def _with_kind(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
story: StoryView,
) -> StoryView:
if story.media_kind or not story.storage_key:
return story
if not storage.exists(story.storage_key):
return story
kind = sniff_media_kind(storage.head(story.storage_key))
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
return story.model_copy(update={"media_kind": kind})
@router.get("/stories") @router.get("/stories")
async def list_stories( async def list_stories(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: AccountId, account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None, peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0, offset: Annotated[int, Query()] = 0,
) -> list[StoryView]: ) -> list[StoryView]:
return await peers.get_stories( stories = await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
) )
return [await _with_kind(pool, storage, account_id, story) for story in stories]
@router.get("/stories/{peer_id}/{story_id}/media") @router.get("/stories/{peer_id}/{story_id}/media")
+131
View File
@@ -0,0 +1,131 @@
import asyncio
import json
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlsplit
import asyncpg
import httpx2
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import accounts
from utils.storage import ContentAddressedStorage
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
WAIT_SECONDS = 90.0
FETCH_TIMEOUT_SECONDS = 120.0
_POLL_SECONDS = 0.5
SavedMode = Literal["sent", "forwarded", "copied"]
class SavedResult(BaseModel):
status: Literal["done", "pending", "failed"]
chat_id: int | None = None
message_id: int | None = None
mode: SavedMode | None = None
error: str | None = None
job_id: int | None = None
class FetchError(Exception):
pass
async def fetch_file(url: str) -> tuple[bytes, str, str | None]:
if urlsplit(url).scheme not in ("http", "https"):
msg = "url must be http or https"
raise FetchError(msg)
chunks: list[bytes] = []
size = 0
try:
async with (
httpx2.AsyncClient(
timeout=FETCH_TIMEOUT_SECONDS, follow_redirects=True
) as client,
client.stream("GET", url) as response,
):
response.raise_for_status()
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > MAX_UPLOAD_BYTES:
msg = "file is over 200 MB"
raise FetchError(msg)
chunks.append(chunk)
except httpx2.HTTPError as exc:
msg = f"could not fetch {url}: {exc}"
raise FetchError(msg) from exc
name = Path(urlsplit(str(response.url)).path).name or "file"
mime = response.headers.get("content-type", "").split(";")[0].strip() or None
return b"".join(chunks), name, mime
async def _refusal(pool: asyncpg.Pool, account_id: int) -> SavedResult | None:
account = await accounts.get_account(pool, account_id)
if account is None or account.is_bot:
return SavedResult(status="failed", error="bot_account")
return None
async def _outcome(pool: asyncpg.Pool, job_id: int, mode: SavedMode) -> SavedResult:
loop = asyncio.get_running_loop()
deadline = loop.time() + WAIT_SECONDS
while loop.time() < deadline:
await asyncio.sleep(_POLL_SECONDS)
row = await pool.fetchrow(
"SELECT status, error, progress FROM jobs WHERE id = $1", job_id
)
if row is None or row["status"] in ("failed", "canceled"):
error = (row and row["error"]) or "failed"
return SavedResult(status="failed", error=error, job_id=job_id)
if row["status"] == "done":
sent: dict[str, Any] = json.loads(row["progress"])
return SavedResult(
status="done",
chat_id=sent.get("chat_id"),
message_id=sent.get("message_id"),
mode=sent.get("mode", mode),
job_id=job_id,
)
return SavedResult(status="pending", job_id=job_id)
async def send_new( # noqa: PLR0913
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
text: str | None,
file: tuple[bytes, str, str | None] | None,
*,
voice: bool = False,
) -> SavedResult:
refusal = await _refusal(pool, account_id)
if refusal is not None:
return refusal
params: dict[str, Any] = {"text": text}
if file is not None:
data, name, mime = file
params |= {
"upload": storage.stage(data),
"file_name": name,
"mime": mime,
"voice": voice,
}
job_id = await enqueue(pool, account_id, "send_saved", params)
return await _outcome(pool, job_id, "sent")
async def forward(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> SavedResult:
refusal = await _refusal(pool, account_id)
if refusal is not None:
return refusal
job_id = await enqueue(
pool,
account_id,
"forward_saved",
{"chat_id": chat_id, "message_id": message_id},
)
return await _outcome(pool, job_id, "forwarded")
+64
View File
@@ -0,0 +1,64 @@
import asyncio
from typing import Literal
import asyncpg
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import accounts, media
MAX_WAIT_SECONDS = 60.0
_POLL_SECONDS = 1.0
_TRANSCRIBABLE = ("voice", "video_note")
TranscribeStatus = Literal["done", "pending", "failed"]
class TranscribeResult(BaseModel):
status: TranscribeStatus
text: str | None = None
error: str | None = None
job_id: int | None = None
async def _text(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> str | None:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
return item.extracted_text if item else None
async def _job_error(pool: asyncpg.Pool, job_id: int) -> str | None:
row = await pool.fetchrow("SELECT status, error FROM jobs WHERE id = $1", job_id)
if row is None or row["status"] == "canceled":
return "failed"
if row["status"] == "failed":
return row["error"] or "failed"
return None
async def request_transcript(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, wait: float = 0
) -> TranscribeResult:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
if item is None or item.kind not in _TRANSCRIBABLE:
return TranscribeResult(status="failed", error="not_voice")
if item.extracted_text:
return TranscribeResult(status="done", text=item.extracted_text)
account = await accounts.get_account(pool, account_id)
if account is None or account.is_bot:
return TranscribeResult(status="failed", error="bot_account")
job_id = await enqueue(
pool, account_id, "transcribe", {"chat_id": chat_id, "message_id": message_id}
)
loop = asyncio.get_running_loop()
deadline = loop.time() + min(wait, MAX_WAIT_SECONDS)
while loop.time() < deadline:
await asyncio.sleep(_POLL_SECONDS)
text = await _text(pool, account_id, chat_id, message_id)
if text:
return TranscribeResult(status="done", text=text, job_id=job_id)
error = await _job_error(pool, job_id)
if error is not None:
return TranscribeResult(status="failed", error=error, job_id=job_id)
return TranscribeResult(status="pending", job_id=job_id)
+2 -21
View File
@@ -1,12 +1,7 @@
from pyrogram.types import Message from pyrogram.types import Message
from userbot.modules.capture import capture_message from userbot.modules.capture.ingest import ingest_message
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.client import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.stt import is_transcribable
from userbot.modules.stt.gate import safe_transcribe
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event
@PyroClient.on_message() @PyroClient.on_message()
@@ -20,21 +15,7 @@ async def on_message(client: PyroClient, message: Message) -> None:
or message.date is None or message.date is None
): ):
return return
meta = meta_from_chat(message.chat, ctx.contacts.ids) await ingest_message(client, ctx, message)
await ctx.watches.on_message(seen_message(meta.chat_id, message))
toggles = ctx.resolve(meta)
if not toggles.messages:
return
await capture_message(client, message, ctx, toggles)
await notify_bg_event(
ctx.pool, "message", ctx.account_id, chat_id=meta.chat_id, message_id=message.id
)
if (
toggles.stt
and is_transcribable(message)
and (message.outgoing or message.unread_media is False)
):
await safe_transcribe(client, ctx, meta.chat_id, message.id)
handlers = on_message.handlers # ty: ignore[unresolved-attribute] handlers = on_message.handlers # ty: ignore[unresolved-attribute]
@@ -0,0 +1,30 @@
from pyrogram import Client
from pyrogram.types import Message
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.capture.message import capture_message
from userbot.modules.stt import is_transcribable
from userbot.modules.stt.gate import safe_transcribe
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event
async def ingest_message(client: Client, ctx: CaptureContext, message: Message) -> None:
if message.chat is None:
return
meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_message(seen_message(meta.chat_id, message))
toggles = ctx.resolve(meta)
if not toggles.messages:
return
await capture_message(client, message, ctx, toggles)
await notify_bg_event(
ctx.pool, "message", ctx.account_id, chat_id=meta.chat_id, message_id=message.id
)
if (
toggles.stt
and is_transcribable(message)
and (message.outgoing or message.unread_media is False)
):
await safe_transcribe(client, ctx, meta.chat_id, message.id)
@@ -7,6 +7,7 @@ from userbot.modules.jobs.handlers import (
fetch_media, fetch_media,
fetch_read_info, fetch_read_info,
search_peers, search_peers,
send_saved,
sync_contacts, sync_contacts,
sync_dialogs, sync_dialogs,
transcribe, transcribe,
@@ -21,6 +22,7 @@ __all__ = [
"fetch_media", "fetch_media",
"fetch_read_info", "fetch_read_info",
"search_peers", "search_peers",
"send_saved",
"sync_contacts", "sync_contacts",
"sync_dialogs", "sync_dialogs",
"transcribe", "transcribe",
@@ -0,0 +1,32 @@
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.saved import forward, send_new
@register("send_saved")
async def send_saved(ctx: JobContext) -> None:
client = ctx.client
capture = getattr(client, "capture", None)
if client is None or capture is None:
return
sent = await send_new(client, capture, ctx.job.params)
await ctx.report_progress(
{"chat_id": sent.chat.id if sent.chat else None, "message_id": sent.id}
)
@register("forward_saved")
async def forward_saved(ctx: JobContext) -> None:
client = ctx.client
capture = getattr(client, "capture", None)
if client is None or capture is None:
return
params = ctx.job.params
sent, mode = await forward(client, capture, params["chat_id"], params["message_id"])
await ctx.report_progress(
{
"chat_id": sent.chat.id if sent.chat else None,
"message_id": sent.id,
"mode": mode,
}
)
+158
View File
@@ -0,0 +1,158 @@
from io import BytesIO
from typing import Any
from pyrogram import Client, enums
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Message, MessageEntity
from userbot.modules.capture.context import CaptureContext
from userbot.modules.capture.ingest import ingest_message
from utils.files import media_file_name
from utils.logging import logger
from utils.read import chats, media
from utils.read.models import EntityView, MessageView
SAVED = "me"
PLAIN = enums.ParseMode.DISABLED
PHOTO_MIMES = ("image/jpeg", "image/png", "image/webp")
class SavedError(Exception):
pass
def _file(data: bytes, name: str) -> BytesIO:
file = BytesIO(data)
file.name = name
return file
def _entity(view: EntityView) -> MessageEntity | None:
kind = enums.MessageEntityType.__members__.get(view.type.upper())
if kind is None or kind is enums.MessageEntityType.TEXT_MENTION:
return None
return MessageEntity(
type=kind,
offset=view.offset,
length=view.length,
url=view.url,
language=view.language,
custom_emoji_id=view.custom_emoji_id,
)
def _entities(view: MessageView) -> list[MessageEntity] | None:
found = [entity for entity in map(_entity, view.entities) if entity is not None]
return found or None
async def _send_file( # noqa: PLR0911
client: Client,
kind: str,
file: BytesIO,
caption: str | None,
entities: list[MessageEntity] | None,
) -> Message | None:
text: dict[str, Any] = {
"caption": caption or "",
"caption_entities": entities,
"parse_mode": PLAIN,
}
match kind:
case "photo":
return await client.send_photo(SAVED, file, **text)
case "video":
return await client.send_video(SAVED, file, **text)
case "voice":
return await client.send_voice(SAVED, file, **text)
case "audio":
return await client.send_audio(SAVED, file, **text)
case "animation":
return await client.send_animation(SAVED, file, **text)
case "video_note":
return await client.send_video_note(SAVED, file)
case "sticker":
return await client.send_sticker(SAVED, file)
return await client.send_document(SAVED, file, file_name=file.name, **text)
def _upload_kind(mime: str | None, *, voice: bool) -> str:
if voice:
return "voice"
if mime in PHOTO_MIMES:
return "photo"
if mime and mime.startswith("video/"):
return "video"
return "document"
async def send_new(
client: Client, ctx: CaptureContext, params: dict[str, Any]
) -> Message:
text = params.get("text") or None
upload = params.get("upload")
if upload is None:
if not text:
msg = "nothing_to_send"
raise SavedError(msg)
sent = await client.send_message(SAVED, text, parse_mode=PLAIN)
else:
data = ctx.storage.staged(upload).read_bytes()
kind = _upload_kind(params.get("mime"), voice=bool(params.get("voice")))
sent = await _send_file(
client, kind, _file(data, params.get("file_name") or upload), text, None
)
ctx.storage.unstage(upload)
return await _keep(client, ctx, sent)
async def _copy(
client: Client, ctx: CaptureContext, view: MessageView
) -> Message | None:
entities = _entities(view)
item = None
if view.has_media and view.web_page is None:
item = await media.get_message_media(
ctx.pool, ctx.account_id, view.chat_id, view.message_id
)
if item is None:
if not view.text:
msg = "nothing_to_copy"
raise SavedError(msg)
return await client.send_message(
SAVED, view.text, entities=entities, parse_mode=PLAIN
)
key = item.storage_key
if not (item.downloaded and key and ctx.storage.exists(key)):
msg = "media_missing"
raise SavedError(msg)
name = media_file_name(item.kind, item.mime, view.message_id, item.file_name)
return await _send_file(
client, item.kind, _file(ctx.storage.get(key), name), view.text, entities
)
async def forward(
client: Client, ctx: CaptureContext, chat_id: int, message_id: int
) -> tuple[Message, str]:
view = await chats.get_message(ctx.pool, ctx.account_id, chat_id, message_id)
if view is None:
msg = "not_archived"
raise SavedError(msg)
if view.deleted_at is None and not view.is_self_destruct:
try:
sent = await client.forward_messages(SAVED, chat_id, message_id)
return await _keep(client, ctx, sent), "forwarded"
except (FloodWait, FloodPremiumWait):
raise
except RPCError as exc:
logger.info(f"[yellow]Forward {chat_id}/{message_id} refused: {exc}[/]")
return await _keep(client, ctx, await _copy(client, ctx, view)), "copied"
async def _keep(client: Client, ctx: CaptureContext, sent: Message | None) -> Message:
if sent is None:
msg = "not_sent"
raise SavedError(msg)
await ingest_message(client, ctx, sent)
return sent
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
INSERT INTO stories INSERT INTO stories
(account_id, peer_id, story_id, date, expire_date, caption, media_kind, (account_id, peer_id, story_id, date, expire_date, caption, media_kind,
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at) storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
date = EXCLUDED.date, date = EXCLUDED.date,
expire_date = EXCLUDED.expire_date, expire_date = EXCLUDED.expire_date,
@@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
downloaded = stories.downloaded OR EXCLUDED.downloaded, downloaded = stories.downloaded OR EXCLUDED.downloaded,
views = EXCLUDED.views, views = EXCLUDED.views,
pinned = EXCLUDED.pinned, pinned = EXCLUDED.pinned,
deleted = EXCLUDED.deleted, deleted = false,
raw = EXCLUDED.raw raw = EXCLUDED.raw
""" """
_MARK_DELETED = """
INSERT INTO stories
(account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true
"""
async def is_downloaded( async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
raw: str, raw: str,
*, *,
pinned: bool, pinned: bool,
deleted: bool,
downloaded: bool, downloaded: bool,
) -> None: ) -> None:
await pool.execute( await pool.execute(
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
downloaded, downloaded,
views, views,
pinned, pinned,
deleted,
raw, raw,
) )
async def mark_deleted(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> None:
await pool.execute(_MARK_DELETED, account_id, peer_id, story_id)
@@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int:
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None: async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story) peer_id = story_peer_id(story)
if story.deleted:
await repository.mark_deleted(
capture.pool, capture.account_id, peer_id, story.id
)
return
storage_key: str | None = None storage_key: str | None = None
file_size: int | None = None file_size: int | None = None
downloaded = False downloaded = False
stored = await repository.is_downloaded( stored = await repository.is_downloaded(
capture.pool, capture.account_id, peer_id, story.id capture.pool, capture.account_id, peer_id, story.id
) )
if not (stored or story.deleted or story.media is None): if not (stored or story.media is None):
data = await download_bytes(client, story) data = await download_bytes(client, story)
if data is not None: if data is not None:
storage_key = capture.storage.put(data) storage_key = capture.storage.put(data)
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
story.views, story.views,
raw_json(story.raw), raw_json(story.raw),
pinned=bool(story.pinned), pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded, downloaded=downloaded,
) )
+2 -2
View File
@@ -2,7 +2,7 @@ from pyrogram import Client
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.stt.service import transcribe_message from userbot.modules.stt.service import TranscribeError, transcribe_message
from utils.logging import logger from utils.logging import logger
@@ -20,5 +20,5 @@ async def safe_transcribe(
"transcribe", "transcribe",
{"chat_id": chat_id, "message_id": message_id}, {"chat_id": chat_id, "message_id": message_id},
) )
except RPCError as exc: except (TranscribeError, RPCError) as exc:
logger.warning(f"[yellow]STT failed for {chat_id}/{message_id}: {exc}[/]") logger.warning(f"[yellow]STT failed for {chat_id}/{message_id}: {exc}[/]")
+36 -15
View File
@@ -1,4 +1,5 @@
from pyrogram import Client, raw from pyrogram import Client, raw
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Message from pyrogram.types import Message
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
@@ -6,6 +7,16 @@ from userbot.modules.media import self_destruct_ttl
from userbot.modules.stt import repository from userbot.modules.stt import repository
from utils.logging import logger from utils.logging import logger
REFUSALS = {
"MSG_VOICE_TOO_LONG": "too_long",
"MSG_VOICE_MISSING": "not_voice",
"PREMIUM_ACCOUNT_REQUIRED": "no_premium",
}
class TranscribeError(Exception):
pass
def is_transcribable(message: Message) -> bool: def is_transcribable(message: Message) -> bool:
if self_destruct_ttl(message) is not None: if self_destruct_ttl(message) is not None:
@@ -26,27 +37,37 @@ def should_transcribe_on_backfill(message: Message, self_id: int | None) -> bool
return not message.unread_media return not message.unread_media
async def _ask(
client: Client, chat_id: int, message_id: int
) -> raw.types.messages.TranscribedAudio:
try:
peer = await client.resolve_peer(chat_id)
if peer is None:
msg = "failed"
raise TranscribeError(msg)
return await client.invoke(
raw.functions.messages.TranscribeAudio(peer=peer, msg_id=message_id)
)
except (FloodWait, FloodPremiumWait):
raise
except RPCError as exc:
logger.warning(f"[yellow]STT refused {chat_id}/{message_id}: {exc}[/]")
raise TranscribeError(REFUSALS.get(exc.ID or "", "failed")) from exc
async def transcribe_message( async def transcribe_message(
client: Client, ctx: CaptureContext, chat_id: int, message_id: int client: Client, ctx: CaptureContext, chat_id: int, message_id: int
) -> None: ) -> None:
peer = await client.resolve_peer(chat_id) result = await _ask(client, chat_id, message_id)
if peer is None:
return
result = await client.invoke(
raw.functions.messages.TranscribeAudio(peer=peer, msg_id=message_id)
)
if result.pending: if result.pending:
logger.info( logger.info(
f"[yellow]STT pending {chat_id}/{message_id} " f"[yellow]STT pending {chat_id}/{message_id} "
f"(trial_remains={result.trial_remains_num})[/]" f"(trial_remains={result.trial_remains_num})[/]"
) )
return return
if result.text: if not result.text:
await repository.set_extracted_text( msg = "empty"
ctx.pool, ctx.account_id, chat_id, message_id, result.text raise TranscribeError(msg)
) await repository.set_extracted_text(
else: ctx.pool, ctx.account_id, chat_id, message_id, result.text
logger.info( )
f"[yellow]STT empty {chat_id}/{message_id} "
f"(trial_remains={result.trial_remains_num})[/]"
)
+5
View File
@@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}" return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
def sniff_media_kind(head: bytes) -> str:
is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3")
return "video" if is_video else "photo"
def resolve_mime( def resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None kind: str | None, mime: str | None, file_name: str | None = None
) -> str: ) -> str:
+72 -1
View File
@@ -1,9 +1,34 @@
import re
from collections import Counter
import asyncpg import asyncpg
from utils.read.models import AnnotationView, Page from utils.read.chats import get_chats
from utils.read.models import AnnotationView, NoteTag, NoteView, Page
_COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at" _COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at"
TAG = re.compile(r"(?<!\w)#(\w+)")
WORD = re.compile(r"\w+")
_NOTES = """
SELECT a.id, a.account_id, a.chat_id, a.message_id, a.text,
a.created_at, a.updated_at,
m.text AS message_text, m.date AS message_date,
COALESCE(m.has_media, false) AS message_has_media,
m.deleted_at IS NOT NULL AS message_deleted
FROM annotations a
LEFT JOIN LATERAL (
SELECT text, date, has_media, deleted_at FROM messages
WHERE account_id = a.account_id AND chat_id = a.chat_id
AND message_id = a.message_id
LIMIT 1
) m ON true
WHERE a.account_id = $1 AND ($4::text IS NULL OR a.text ~* $4)
ORDER BY a.created_at DESC, a.id DESC
LIMIT $2 OFFSET $3
"""
async def list_annotations( async def list_annotations(
pool: asyncpg.Pool, pool: asyncpg.Pool,
@@ -70,3 +95,49 @@ async def update_annotation(
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool: async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id) result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
return result.endswith("1") return result.endswith("1")
def _tag_pattern(tag: str | None) -> str | None:
if tag is None:
return None
return f"(^|[^[:alnum:]_])#{tag}($|[^[:alnum:]_])"
async def list_notes(
pool: asyncpg.Pool, account_id: int, page: Page, *, tag: str | None = None
) -> list[NoteView]:
if tag is not None and not WORD.fullmatch(tag):
return []
rows = await pool.fetch(
_NOTES, account_id, page.capped_limit, page.offset, _tag_pattern(tag)
)
chats = {
chat.chat_id: chat
for chat in await get_chats(
pool, account_id, list({row["chat_id"] for row in rows})
)
}
notes: list[NoteView] = []
for row in rows:
chat = chats.get(row["chat_id"])
notes.append(
NoteView(
**dict(row),
chat_title=chat.title if chat else None,
chat_has_avatar=chat.has_avatar if chat else False,
)
)
return notes
async def list_tags(pool: asyncpg.Pool, account_id: int) -> list[NoteTag]:
rows = await pool.fetch(
"SELECT text FROM annotations WHERE account_id = $1", account_id
)
counts: Counter[str] = Counter()
for row in rows:
counts.update({tag.lower() for tag in TAG.findall(row["text"])})
return [
NoteTag(tag=tag, count=count)
for tag, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))
]
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
import asyncpg
from utils.read.models import CaptureGap, CaptureStatus
_STATUS = """
SELECT started_at, seen_at, seen_at >= now() - interval '150 seconds' AS live
FROM capture_uptime WHERE account_id = $1
ORDER BY started_at DESC LIMIT 1
"""
_GAPS = """
SELECT seen_at AS start, next_start AS "end"
FROM (
SELECT seen_at,
lead(started_at) OVER (ORDER BY started_at) AS next_start
FROM capture_uptime WHERE account_id = $1
) stretches
WHERE next_start - seen_at > interval '30 minutes'
AND ($2::timestamptz IS NULL OR next_start >= $2)
AND ($3::timestamptz IS NULL OR seen_at <= $3)
ORDER BY seen_at
"""
async def capture_status(pool: asyncpg.Pool, account_id: int) -> CaptureStatus:
row = await pool.fetchrow(_STATUS, account_id)
if row is None:
return CaptureStatus(live=False, since=None, last_seen=None)
return CaptureStatus(
live=row["live"], since=row["started_at"], last_seen=row["seen_at"]
)
async def capture_gaps(
pool: asyncpg.Pool,
account_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[CaptureGap]:
rows = await pool.fetch(_GAPS, account_id, date_from, date_to)
return [CaptureGap(**dict(row)) for row in rows]
+23 -1
View File
@@ -80,7 +80,8 @@ _CHAT_ROWS = """
WITH ids AS ({ids}) WITH ids AS ({ids})
SELECT ids.chat_id, SELECT ids.chat_id,
COALESCE(cs.message_count, 0) AS message_count, COALESCE(cs.message_count, 0) AS message_count,
cs.last_date, cs.last_text, cs.last_sender_id, cs.last_date, cs.last_text, cs.last_sender_id, cs.last_message_id,
COALESCE(cs.last_deleted, false) AS last_deleted,
COALESCE(named.title, COALESCE(named.title,
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''), NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
p.username) AS title, p.username) AS title,
@@ -149,6 +150,8 @@ def _chat_item(row: asyncpg.Record) -> ChatListItem:
last_date=row["last_date"], last_date=row["last_date"],
last_text=row["last_text"], last_text=row["last_text"],
last_sender_id=row["last_sender_id"], last_sender_id=row["last_sender_id"],
last_message_id=row["last_message_id"],
last_deleted=row["last_deleted"],
) )
@@ -259,6 +262,7 @@ async def get_chat_history( # noqa: PLR0913
views.append(_build_album(members, media_by_key)) views.append(_build_album(members, media_by_key))
index = end index = end
await _apply_read_status(pool, account_id, chat_id, views) await _apply_read_status(pool, account_id, chat_id, views)
await _apply_notes(pool, account_id, chat_id, views)
return views return views
@@ -278,6 +282,7 @@ async def get_message(
raw = load_raw(row["raw"]) raw = load_raw(row["raw"])
view = build_message_view(row, raw, _single_media(row, raw, media_by_key)) view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
await _apply_read_status(pool, account_id, chat_id, [view]) await _apply_read_status(pool, account_id, chat_id, [view])
await _apply_notes(pool, account_id, chat_id, [view])
return view return view
@@ -301,6 +306,23 @@ async def _apply_read_status(
) )
async def _apply_notes(
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
) -> None:
if not views:
return
rows = await pool.fetch(
"SELECT DISTINCT message_id FROM annotations "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = ANY($3::bigint[])",
account_id,
chat_id,
[view.message_id for view in views],
)
noted = {row["message_id"] for row in rows}
for view in views:
view.has_note = view.message_id in noted
def _build_album( def _build_album(
members: list[tuple[asyncpg.Record, dict]], members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record], media_by_key: dict[tuple[int, int], asyncpg.Record],
+59 -8
View File
@@ -1,9 +1,11 @@
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
import asyncpg import asyncpg
from utils.policy import repository as policy_repository
from utils.read import chats, peers from utils.read import chats, peers
from utils.read.accounts import self_user_id from utils.read.accounts import self_user_id
from utils.read.models import ( from utils.read.models import (
@@ -24,7 +26,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 = """
@@ -43,6 +46,13 @@ LEFT JOIN LATERAL (
) p ON true ) p ON true
""" """
_IS_BOT = """EXISTS (
SELECT 1 FROM peers p
WHERE p.account_id = feed_items.account_id AND p.peer_id = feed_items.chat_id
AND COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool,
p.raw->>'type' = 'ChatType.BOT', false)
)"""
_UNSEEN = """ _UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted, SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited count(*) FILTER (WHERE kind = 'edited') AS edited
@@ -59,21 +69,33 @@ 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):
DM = "dm" DM = "dm"
BOT = "bot"
GROUP = "group" GROUP = "group"
CHANNEL = "channel" CHANNEL = "channel"
_CHAT_KIND_CLAUSES = {
FeedChatKind.DM: f"chat_kind = 'dm' AND NOT {_IS_BOT}",
FeedChatKind.BOT: f"chat_kind = 'dm' AND {_IS_BOT}",
FeedChatKind.GROUP: "chat_kind = 'group'",
FeedChatKind.CHANNEL: "chat_kind = 'channel'",
}
@dataclass(slots=True) @dataclass(slots=True)
class FeedFilter: class FeedFilter:
kind: FeedKind = FeedKind.ALL kind: FeedKind = FeedKind.ALL
chat_id: int | None = None chat_id: int | None = None
chat_kind: FeedChatKind | None = None chat_kind: FeedChatKind | None = None
folder_id: int | None = None
since: datetime | None = None since: datetime | None = None
@@ -86,15 +108,34 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
return _EPOCH + int(micros) * _MICROSECOND, int(key) return _EPOCH + int(micros) * _MICROSECOND, int(key)
async def _folder_chat_ids(
pool: asyncpg.Pool, account_id: int, folder_id: int | None
) -> list[int] | None:
if folder_id is None:
return None
folder = await policy_repository.get_folder(pool, account_id, folder_id)
if folder is None:
return []
return await chats.folder_chat_ids(pool, account_id, folder)
def _where( def _where(
account_id: int, feed: FeedFilter, cursor: str | None account_id: int,
feed: FeedFilter,
folder_chat_ids: list[int] | None,
cursor: str | None,
) -> 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'")
if feed.chat_kind is not None:
clauses.append(_CHAT_KIND_CLAUSES[feed.chat_kind])
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_id = ANY({}::bigint[])", folder_chat_ids),
("at >= {}", feed.since), ("at >= {}", feed.since),
) )
for template, value in conditions: for template, value in conditions:
@@ -153,7 +194,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 = {
@@ -180,7 +225,9 @@ async def _build_items(
chat=FeedChat( chat=FeedChat(
chat_id=row["chat_id"], chat_id=row["chat_id"],
title=chat.title if chat else None, title=chat.title if chat else None,
kind=row["chat_kind"], kind=FeedChatKind.BOT
if chat and chat.is_bot and row["chat_kind"] == FeedChatKind.DM
else row["chat_kind"],
has_avatar=chat.has_avatar if chat else False, has_avatar=chat.has_avatar if chat else False,
), ),
message_id=row["message_id"], message_id=row["message_id"],
@@ -192,6 +239,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
@@ -206,7 +254,8 @@ async def list_feed(
cursor: str | None = None, cursor: str | None = None,
) -> FeedPage: ) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT)) size = max(1, min(limit, MAX_LIMIT))
where, params = _where(account_id, feed, cursor) folder_chat_ids = await _folder_chat_ids(pool, account_id, feed.folder_id)
where, params = _where(account_id, feed, folder_chat_ids, cursor)
params.append(size + 1) params.append(size + 1)
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608 f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
@@ -226,7 +275,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"],
+14 -1
View File
@@ -3,7 +3,7 @@ import json
import asyncpg import asyncpg
from utils.files import media_file_name from utils.files import media_file_name
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
MEDIA_COLS = ( MEDIA_COLS = (
"m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, " "m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, "
@@ -23,6 +23,12 @@ LEFT JOIN LATERAL (
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at" _VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
_PLACES = """
SELECT account_id, chat_id, message_id, kind, extracted_text FROM media
WHERE storage_key = $1 AND ($2::bigint IS NULL OR account_id = $2)
ORDER BY created_at DESC LIMIT 100
"""
_WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio") _WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio")
@@ -100,6 +106,13 @@ async def get_message_media(
return await _web_page_media_stub(pool, account_id, chat_id, message_id) return await _web_page_media_stub(pool, account_id, chat_id, message_id)
async def find_by_content(
pool: asyncpg.Pool, storage_key: str, account_id: int | None = None
) -> list[MediaPlaceView]:
rows = await pool.fetch(_PLACES, storage_key, account_id)
return [MediaPlaceView(**dict(row)) for row in rows]
async def get_media_versions( async def get_media_versions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MediaVersionView]: ) -> list[MediaVersionView]:
+37
View File
@@ -38,6 +38,8 @@ class ChatListItem(BaseModel):
last_date: datetime | None last_date: datetime | None
last_text: str | None last_text: str | None
last_sender_id: int | None last_sender_id: int | None
last_message_id: int | None = None
last_deleted: bool = False
class DiscoverItem(BaseModel): class DiscoverItem(BaseModel):
@@ -204,6 +206,7 @@ class MessageView(BaseModel):
is_animated_emoji: bool = False is_animated_emoji: bool = False
read: bool = False read: bool = False
read_at: datetime | None = None read_at: datetime | None = None
has_note: bool = False
class MessageVersionView(BaseModel): class MessageVersionView(BaseModel):
@@ -235,6 +238,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):
@@ -271,6 +275,14 @@ class MediaView(BaseModel):
file_name: str | None = None file_name: str | None = None
class MediaPlaceView(BaseModel):
account_id: int
chat_id: int
message_id: int
kind: str
extracted_text: str | None
class MediaVersionView(BaseModel): class MediaVersionView(BaseModel):
id: int id: int
kind: str kind: str
@@ -610,6 +622,31 @@ class AnnotationView(BaseModel):
updated_at: datetime updated_at: datetime
class NoteView(AnnotationView):
chat_title: str | None
chat_has_avatar: bool
message_text: str | None
message_date: datetime | None
message_has_media: bool
message_deleted: bool
class NoteTag(BaseModel):
tag: str
count: int
class CaptureStatus(BaseModel):
live: bool
since: datetime | None
last_seen: datetime | None
class CaptureGap(BaseModel):
start: datetime
end: datetime
class WatchView(BaseModel): class WatchView(BaseModel):
id: int id: int
account_id: int account_id: int
+14 -1
View File
@@ -76,7 +76,7 @@ async def get_stories(
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608 "SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
"storage_key, downloaded, views, pinned, deleted FROM stories " "storage_key, downloaded, views, pinned, deleted FROM stories "
f"WHERE {where} " f"WHERE {where} "
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} " f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} "
f"OFFSET ${len(params)}", f"OFFSET ${len(params)}",
*params, *params,
) )
@@ -95,3 +95,16 @@ async def get_story(
story_id, story_id,
) )
return StoryView(**dict(row)) if row else None return StoryView(**dict(row)) if row else None
async def set_story_kind(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str
) -> None:
await pool.execute(
"UPDATE stories SET media_kind = $4 "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
kind,
)
+23
View File
@@ -48,6 +48,29 @@ async def current_presence(
return PresenceSample(**dict(row)) if row is not None else None return PresenceSample(**dict(row)) if row is not None else None
_ONLINE = """
SELECT peer_id, ts, status, last_online_date, next_offline_date
FROM (
SELECT DISTINCT ON (peer_id)
peer_id, ts, status, last_online_date, next_offline_date
FROM presence
WHERE account_id = $1 AND peer_id = ANY($2::bigint[])
AND ts > now() - interval '1 hour'
ORDER BY peer_id, ts DESC
) latest
WHERE status = 'online' AND next_offline_date > now()
"""
async def online_now(
pool: asyncpg.Pool, account_id: int, peer_ids: list[int]
) -> list[PresenceSample]:
if not peer_ids:
return []
rows = await pool.fetch(_ONLINE, account_id, peer_ids)
return [PresenceSample(**dict(row)) for row in rows]
async def presence_hourly( async def presence_hourly(
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
+65
View File
@@ -0,0 +1,65 @@
import re
from collections.abc import Iterator
from typing import Any
from utils.search.models import Found, FoundWhere
HIDDEN_KEYS = ("chat", "from_user", "sender_chat")
SKIPPED_KEYS = ("_", "file_id", "file_unique_id")
ENUM = re.compile(r"^[A-Z][A-Za-z]+\.[A-Z_]+$")
_HEADS: dict[str, FoundWhere] = {
"text": "text",
"caption": "text",
"web_page": "preview",
}
_LEAVES: dict[str, FoundWhere] = {"url": "link", "file_name": "file"}
type Path = tuple[str | int, ...]
def _scalars(node: object, path: Path = ()) -> Iterator[tuple[Path, str, object]]:
if isinstance(node, dict):
items = ((k, v) for k, v in node.items() if k not in SKIPPED_KEYS)
elif isinstance(node, list):
items = enumerate(node)
else:
return
for key, value in items:
if isinstance(value, dict | list):
yield from _scalars(value, (*path, key))
elif isinstance(value, str | int | float) and not isinstance(value, bool):
yield (*path, key), str(value), node
def _where(path: Path) -> FoundWhere:
head, leaf = str(path[0]), str(path[-1])
if head == "reply_markup":
return "button"
if leaf in _LEAVES:
return _LEAVES[leaf]
if head.startswith("forward"):
return "forward"
if head.startswith("reply_to"):
return "reply"
return _HEADS.get(head, "data")
def find(raw: dict[str, Any], needle: str) -> Found | None:
folded = needle.casefold()
fallback = None
for path, value, owner in _scalars(raw):
if folded not in value.casefold() or ENUM.match(value):
continue
where = _where(path)
button = owner.get("text") if isinstance(owner, dict) else None
found = Found(
where=where,
path=".".join(map(str, path)),
value=value,
button=button if where == "button" else None,
)
if where != "text":
return found
fallback = fallback or found
return fallback
+55 -5
View File
@@ -1,23 +1,59 @@
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", "data"]
FoundWhere = Literal[
"text", "button", "link", "forward", "reply", "file", "preview", "data"
]
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
everywhere: bool = False
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 Found(BaseModel):
where: FoundWhere
path: str
value: str
button: str | None = None
class SearchHit(BaseModel): class SearchHit(BaseModel):
@@ -26,5 +62,19 @@ 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
extracted_text: str | None media_kind: str | None
edited_at: datetime | None
deleted_at: datetime | None
source: HitSource
extracted_text: str | None = None
version_text: str | None = None
version_at: datetime | None = None
found: Found | None = None
class SearchPage(BaseModel):
hits: list[SearchHit]
next_cursor: str | None
terms: list[str]
scanned_to: datetime | None = None
+262 -71
View File
@@ -1,88 +1,279 @@
import json
import re
from datetime import UTC, datetime, timedelta
from time import monotonic
import asyncpg import asyncpg
from utils.search.models import MAX_LIMIT, SearchFilters, SearchHit from utils.read.message_view import load_raw
from utils.search.found import ENUM, HIDDEN_KEYS, SKIPPED_KEYS, find
_TEXT_TSV = "to_tsvector('russian', coalesce(m.text, ''))" from utils.search.models import (
_STT_TSV = "to_tsvector('russian', coalesce(md.extracted_text, ''))" MAX_LIMIT,
_TSQUERY = "websearch_to_tsquery('russian', ${})" ChatKind,
SearchFilters,
_HITS = "{cols} FROM messages m WHERE {where}" # text branch SearchHit,
_STT_HITS = ( SearchPage,
"{cols} FROM messages m " decode_cursor,
"JOIN media md ON md.account_id = m.account_id " encode_cursor,
"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, " MIN_NEEDLE = 3
"'text' AS source, NULL::text AS extracted_text" _WINDOW = timedelta(days=7)
_BUDGET_SECONDS = 2.5
_BOTTOM = -(2**63)
_LIKE_SPECIAL = re.compile(r"([\\%_])")
_PARSE = """
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
|| 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
"""
_CHANNELS = """
SELECT coalesce(array_agg(chat_id), '{}'::bigint[]) FROM (
SELECT DISTINCT ON (chat_id) chat_id,
coalesce(raw->'chat'->>'type', raw->>'type') AS type
FROM chat_history
WHERE account_id = $1
AND coalesce(raw->'chat'->>'type', raw->>'type') IS NOT NULL
ORDER BY chat_id, ts DESC
) typed WHERE type = 'ChatType.CHANNEL'
"""
_KEY_ORDER = "s.date DESC, s.chat_id DESC, s.message_id DESC"
_COLS = (
"s.chat_id, s.message_id, s.date, s.sender_id, s.text, s.has_media, "
"s.edited_at, s.deleted_at"
) )
_STT_COLS = (
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, " _TEXT = (
"'stt' AS source, md.extracted_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")
_OLDEST = "SELECT min(date) FROM message_search WHERE account_id = $1"
_VALUE_MATCH = (
'strict $.** ? (@.type() == "object").keyvalue() ? ('
+ "".join(f'@.key != "{key}" && ' for key in SKIPPED_KEYS)
+ '(@.value.type() == "string" || @.value.type() == "number") && '
f"!(@.value.string() like_regex {json.dumps(ENUM.pattern)}) && "
'@.value.string() like_regex {needle} flag "iq")'
)
_EVERYWHERE = (
f"SELECT {_COLS}, s.raw - {{hidden}}::text[] AS raw " # noqa: S608
"FROM messages s WHERE {where} "
"AND CASE WHEN s.raw::text ILIKE {like} "
"THEN jsonb_path_exists(s.raw - {hidden}::text[], {path}::jsonpath) "
f"ELSE false END ORDER BY {_KEY_ORDER} LIMIT {{limit}}"
) )
def _common_conditions(filters: SearchFilters, params: list[object]) -> list[str]: class _Binder:
params.append(filters.account_id) def __init__(self) -> None:
conds = [f"m.account_id = ${len(params)}"] self.params: list[object] = []
if filters.chat_id is not None:
params.append(filters.chat_id) def __call__(self, value: object) -> str:
conds.append(f"m.chat_id = ${len(params)}") self.params.append(value)
if filters.sender_id is not None: return f"${len(self.params)}"
params.append(filters.sender_id)
conds.append(f"m.sender_id = ${len(params)}")
if filters.has_media is not None: def _kind(kind: ChatKind, channels: list[int], bind: _Binder) -> str:
params.append(filters.has_media) if kind == "private":
conds.append(f"m.has_media = ${len(params)}") return "s.chat_id > 0"
if filters.date_from is not None: member = f"s.chat_id = ANY({bind(channels)}::bigint[])"
params.append(filters.date_from) return member if kind == "channel" else f"s.chat_id < 0 AND NOT {member}"
conds.append(f"m.date >= ${len(params)}")
if filters.date_to is not None:
params.append(filters.date_to) def _conditions(
conds.append(f"m.date <= ${len(params)}") 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")
if filters.everywhere:
return await _everywhere(conn, filters)
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 _channels(conn: asyncpg.Connection, filters: SearchFilters) -> list[int]:
limit_idx = len(params) if filters.kind in {"group", "channel"}:
params.append(filters.offset) return await conn.fetchval(_CHANNELS, filters.account_id)
offset_idx = len(params) return []
page = f"ORDER BY date DESC LIMIT ${limit_idx} OFFSET ${offset_idx}"
if len(branches) == 1:
sql = f"SELECT * FROM ({branches[0]}) hits {page}" # noqa: S608 def _data_hit(row: asyncpg.Record, needle: str) -> SearchHit:
else: data = dict(row)
union = " UNION ALL ".join(f"({b})" for b in branches) raw = load_raw(data.pop("raw"))
dedup = ( return SearchHit(**data, source="data", found=find(raw, needle))
"SELECT DISTINCT ON (chat_id, message_id) * " # noqa: S608
f"FROM ({union}) hits ORDER BY chat_id, message_id, source DESC"
async def _everywhere(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
needle = (filters.query or "").strip()
floor = filters.date_from or await conn.fetchval(_OLDEST, filters.account_id)
if len(needle) < MIN_NEEDLE or floor is None:
return SearchPage(hits=[], next_cursor=None, terms=[])
channels = await _channels(conn, filters)
quoted = json.dumps(needle, ensure_ascii=False)
like = _LIKE_SPECIAL.sub(r"\\\1", quoted[1:-1])
path = _VALUE_MATCH.format(needle=quoted)
size = min(filters.limit, MAX_LIMIT)
cursor = filters.cursor
top = decode_cursor(cursor)[0] if cursor else None
hits: list[SearchHit] = []
started = monotonic()
while True:
lower = max((top or filters.date_to or datetime.now(UTC)) - _WINDOW, floor)
bind = _Binder()
conds = _conditions(
filters.model_copy(update={"cursor": cursor}), channels, bind
) )
sql = f"SELECT * FROM ({dedup}) d {page}" # noqa: S608 conds.append(f"s.date >= {bind(lower)}")
if top is not None:
conds.append(f"s.date <= {bind(top)}")
limit = size - len(hits)
sql = _EVERYWHERE.format(
where=" AND ".join(conds),
hidden=bind(list(HIDDEN_KEYS)),
like=bind(f"%{like}%"),
path=bind(path),
limit=bind(limit),
)
account = bind(filters.account_id)
rows = await conn.fetch(
_WITH_MEDIA.format(hits=sql, account=account), *bind.params
)
hits += [_data_hit(row, needle) for row in rows]
if len(rows) >= limit:
last = rows[-1]
top = last["date"]
cursor = encode_cursor(top, last["chat_id"], last["message_id"])
elif lower > floor:
top = lower
cursor = encode_cursor(lower, _BOTTOM, _BOTTOM)
else:
return SearchPage(hits=hits, next_cursor=None, terms=[], scanned_to=floor)
if len(hits) >= size or monotonic() - started > _BUDGET_SECONDS:
return SearchPage(hits=hits, next_cursor=cursor, terms=[], scanned_to=top)
rows = await pool.fetch(sql, *params)
return [SearchHit(**dict(row)) for row in rows] async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
query = (filters.query or "").strip()
terms: list[str] = []
tsquery = None
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"]
channels = await _channels(conn, filters)
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:
match = f"{bind(tsquery)}::tsquery"
branches = (
_branch(_TEXT, limit, where=f"{where} AND s.tsv @@ {match}"),
_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}"
)
rows = await conn.fetch(_WITH_MEDIA.format(hits=sql, account=account), *bind.params)
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)
+19
View File
@@ -1,7 +1,9 @@
import hashlib import hashlib
from pathlib import Path from pathlib import Path
from uuid import uuid4
TRASH_DIR = ".trash" TRASH_DIR = ".trash"
UPLOADS_DIR = ".uploads"
class ContentAddressedStorage: class ContentAddressedStorage:
@@ -24,12 +26,29 @@ class ContentAddressedStorage:
def get(self, key: str) -> bytes: def get(self, key: str) -> bytes:
return self._path(key).read_bytes() return self._path(key).read_bytes()
def head(self, key: str, size: int = 16) -> bytes:
with self._path(key).open("rb") as file:
return file.read(size)
def exists(self, key: str) -> bool: def exists(self, key: str) -> bool:
return self._path(key).exists() return self._path(key).exists()
def url(self, key: str) -> str: def url(self, key: str) -> str:
return str(self._path(key)) return str(self._path(key))
def staged(self, name: str) -> Path:
return self._root / UPLOADS_DIR / Path(name).name
def stage(self, data: bytes) -> str:
name = uuid4().hex
path = self.staged(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return name
def unstage(self, name: str) -> None:
self.staged(name).unlink(missing_ok=True)
def _trash_path(self, key: str) -> Path: def _trash_path(self, key: str) -> Path:
return self._root / TRASH_DIR / key return self._root / TRASH_DIR / key
+6
View File
@@ -113,6 +113,12 @@ class BotRegistry:
def running(self) -> list[int]: def running(self) -> list[int]:
return list(self._running) return list(self._running)
@property
def connected_accounts(self) -> list[int]:
return [
bot.account_id for bot in self._running.values() if bot.client.is_connected
]
async def sync(self) -> None: async def sync(self) -> None:
async with self._lock: async with self._lock:
await self._adopt_dropped_in() await self._adopt_dropped_in()
+15 -3
View File
@@ -6,6 +6,7 @@ import asyncpg
import uvloop import uvloop
from dependencies.container import container from dependencies.container import container
from userbot.modules.uptime import repository as uptime
from utils.listen import Listener from utils.listen import Listener
from utils.logging import logger, setup_logging from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL from utils.read.bots import BOTS_CHANGED_CHANNEL
@@ -16,6 +17,7 @@ from watcher.registry import BotRegistry
setup_logging() setup_logging()
RETRY_INTERVAL = 300 RETRY_INTERVAL = 300
UPTIME_INTERVAL = 60
async def _retry_loop(registry: BotRegistry) -> None: async def _retry_loop(registry: BotRegistry) -> None:
@@ -27,6 +29,14 @@ async def _retry_loop(registry: BotRegistry) -> None:
logger.exception("[red]Bot sync failed[/]") logger.exception("[red]Bot sync failed[/]")
async def _keep_uptime(registry: BotRegistry, pool: asyncpg.Pool) -> None:
while True:
for account_id in registry.connected_accounts:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener: def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
def on_change(_payload: str) -> None: def on_change(_payload: str) -> None:
task = asyncio.create_task(registry.sync()) task = asyncio.create_task(registry.sync())
@@ -58,6 +68,7 @@ async def runner() -> None:
stop = asyncio.Event() stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set) asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry)) retry = asyncio.create_task(_retry_loop(registry))
beat = asyncio.create_task(_keep_uptime(registry, pool))
try: try:
await registry.sync() await registry.sync()
if not registry.running: if not registry.running:
@@ -66,9 +77,10 @@ async def runner() -> None:
logger.info("[green]Bot watcher running.[/]") logger.info("[green]Bot watcher running.[/]")
await stop.wait() await stop.wait()
finally: finally:
retry.cancel() for task in (retry, beat):
with contextlib.suppress(asyncio.CancelledError): task.cancel()
await retry with contextlib.suppress(asyncio.CancelledError):
await task
await listener.stop() await listener.stop()
await registry.close() await registry.close()
await container.close() await container.close()
+6 -5
View File
@@ -1,13 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts import type { PanelState } from "#lib/stores/ui.svelte.js";
// for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} interface PageState {
inApp?: boolean;
panel?: PanelState;
}
// interface Platform {} // interface Platform {}
} }
} }
export {};
+32
View File
@@ -0,0 +1,32 @@
import { request } from "#lib/api/client.js";
import type { PresenceSample } from "#lib/api/types.js";
export interface CaptureStatus {
last_seen: string | null;
live: boolean;
since: string | null;
}
export interface CaptureGap {
end: string;
start: string;
}
export function getCaptureStatus(accountId: number): Promise<CaptureStatus> {
return request<CaptureStatus>("/capture/status", {
query: { account_id: accountId },
});
}
export function getCaptureGaps(accountId: number): Promise<CaptureGap[]> {
return request<CaptureGap[]>("/capture/gaps", {
query: { account_id: accountId },
});
}
export function getOnline(peerIds: number[]): Promise<PresenceSample[]> {
return request<PresenceSample[]>("/presence/online", {
account: true,
query: { peer_ids: peerIds.join(",") },
});
}
+12
View File
@@ -95,6 +95,18 @@ export async function request<T>(
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export async function requestForm<T>(path: string, form: FormData): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
body: form,
headers: authHeaders(),
method: "POST",
});
if (!response.ok) {
return handleError(response);
}
return response.json() as Promise<T>;
}
function streamUrl(path: string): string { function streamUrl(path: string): string {
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ""; const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
return `${BASE}${path}${query}`; return `${BASE}${path}${query}`;
+57 -12
View File
@@ -1,4 +1,4 @@
import { request } from "#lib/api/client.js"; import { request, requestForm } from "#lib/api/client.js";
import type { import type {
Account, Account,
Annotation, Annotation,
@@ -41,7 +41,8 @@ import type {
ReadInfo, ReadInfo,
ResponseStats, ResponseStats,
ScopeTarget, ScopeTarget,
SearchHit, SearchPage,
SearchQuery,
StorageChat, StorageChat,
StorageFile, StorageFile,
StorageGroupName, StorageGroupName,
@@ -283,6 +284,7 @@ export interface FeedQuery {
chat_id?: number; chat_id?: number;
chat_kind?: FeedChatKind; chat_kind?: FeedChatKind;
cursor?: string; cursor?: string;
folder_id?: number;
kind?: FeedKind; kind?: FeedKind;
limit?: number; limit?: number;
} }
@@ -555,14 +557,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(
@@ -579,11 +575,60 @@ export function fetchMedia(
}); });
} }
export interface TranscribeResult {
error: string | null;
status: "done" | "failed" | "pending";
text: string | null;
}
export function transcribeMedia( export function transcribeMedia(
chatId: number,
messageId: number,
wait: number
): Promise<TranscribeResult> {
return request<TranscribeResult>("/media/transcribe", {
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
wait,
},
method: "POST",
});
}
export interface SavedResult {
chat_id: number | null;
error: string | null;
message_id: number | null;
mode: "copied" | "forwarded" | "sent" | null;
status: "done" | "failed" | "pending";
}
export interface SavedDraft {
file?: File;
text?: string;
voice?: boolean;
}
export function sendToSaved(draft: SavedDraft): Promise<SavedResult> {
const form = new FormData();
form.set("account_id", String(accounts.selectedId));
if (draft.text) {
form.set("text", draft.text);
}
if (draft.file) {
form.set("file", draft.file);
form.set("voice", String(Boolean(draft.voice)));
}
return requestForm<SavedResult>("/saved", form);
}
export function forwardToSaved(
chatId: number, chatId: number,
messageId: number messageId: number
): Promise<{ job_id: number }> { ): Promise<SavedResult> {
return request<{ job_id: number }>("/media/transcribe", { return request<SavedResult>("/saved/forward", {
body: { body: {
account_id: accounts.selectedId, account_id: accounts.selectedId,
chat_id: chatId, chat_id: chatId,
+45 -23
View File
@@ -3,8 +3,8 @@ import { getMessageMedia, transcribeMedia } from "#lib/api/endpoints.js";
import type { MediaRef } from "#lib/api/types.js"; import type { MediaRef } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
const TRANSCRIBE_TRIES = 10; const TRANSCRIBE_WAIT = 25;
const TRANSCRIBE_DELAY = 2000; const TRANSCRIBE_TRIES = 3;
export type InlineMedia = export type InlineMedia =
| { | {
@@ -15,7 +15,12 @@ export type InlineMedia =
url: string; url: string;
transcript: string | null; transcript: string | null;
} }
| { state: "not-downloaded"; mediaId: number; kind: string } | {
state: "not-downloaded";
mediaId: number;
kind: string;
transcript: string | null;
}
| { state: "missing" }; | { state: "missing" };
export interface ViewerItem { export interface ViewerItem {
@@ -173,7 +178,12 @@ async function resolve(
return { state: "missing" }; return { state: "missing" };
} }
if (!meta.downloaded) { if (!meta.downloaded) {
return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; return {
kind: meta.kind,
mediaId: meta.id,
state: "not-downloaded",
transcript: meta.extracted_text,
};
} }
const blob = await requestMedia(meta.id); const blob = await requestMedia(meta.id);
if (blob.state === "ready") { if (blob.state === "ready") {
@@ -187,7 +197,12 @@ async function resolve(
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; return {
kind: meta.kind,
mediaId: meta.id,
state: "not-downloaded",
transcript: meta.extracted_text,
};
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -200,7 +215,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
return { state: "missing" }; return { state: "missing" };
} }
if (!media.downloaded) { if (!media.downloaded) {
return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; return {
kind: media.kind,
mediaId: media.id,
state: "not-downloaded",
transcript: null,
};
} }
const blob = await requestMedia(media.id); const blob = await requestMedia(media.id);
if (blob.state === "ready") { if (blob.state === "ready") {
@@ -214,7 +234,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; return {
kind: media.kind,
mediaId: media.id,
state: "not-downloaded",
transcript: null,
};
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -257,32 +282,29 @@ function patchTranscript(
return; return;
} }
const cached = ready.get(cacheKey(account, chatId, messageId)); const cached = ready.get(cacheKey(account, chatId, messageId));
if (cached?.state === "ready") { if (cached && cached.state !== "missing") {
cached.transcript = text; cached.transcript = text;
} }
} }
export type TranscriptOutcome = { text: string } | { error: string };
export async function requestTranscription( export async function requestTranscription(
chatId: number, chatId: number,
messageId: number messageId: number
): Promise<string | null> { ): Promise<TranscriptOutcome> {
await transcribeMedia(chatId, messageId);
for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) { for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose // biome-ignore lint/performance/noAwaitInLoops: each try waits on the server
await new Promise((done) => { const result = await transcribeMedia(chatId, messageId, TRANSCRIBE_WAIT);
setTimeout(done, TRANSCRIBE_DELAY); if (result.status === "done" && result.text) {
}); patchTranscript(chatId, messageId, result.text);
try { return { text: result.text };
const meta = await getMessageMedia(chatId, messageId); }
if (meta.extracted_text) { if (result.status === "failed") {
patchTranscript(chatId, messageId, meta.extracted_text); return { error: result.error ?? "failed" };
return meta.extracted_text;
}
} catch {
return null;
} }
} }
return null; return { error: "pending" };
} }
export function loadInlineMedia( export function loadInlineMedia(
+31
View File
@@ -0,0 +1,31 @@
import { request } from "#lib/api/client.js";
import type { Annotation } from "#lib/api/types.js";
export interface Note extends Annotation {
chat_has_avatar: boolean;
chat_title: string | null;
message_date: string | null;
message_deleted: boolean;
message_has_media: boolean;
message_text: string | null;
}
export interface NoteTag {
count: number;
tag: string;
}
export function listNotes(
tag: string | null,
offset: number,
limit: number
): Promise<Note[]> {
return request<Note[]>("/annotations/notes", {
account: true,
query: { limit, offset, tag },
});
}
export function listNoteTags(): Promise<NoteTag[]> {
return request<NoteTag[]>("/annotations/tags", { account: true });
}
+55 -3
View File
@@ -7,7 +7,26 @@ export type PolicyScopeType =
| "default_channel" | "default_channel"
| "folder" | "folder"
| "chat"; | "chat";
export type SearchSource = "text" | "stt"; export type SearchSource = "text" | "stt" | "version" | "data";
export type FoundWhere =
| "text"
| "button"
| "link"
| "forward"
| "reply"
| "file"
| "preview"
| "data";
export interface SearchFound {
button: string | null;
path: string;
value: string;
where: FoundWhere;
}
export type SearchChatKind = "private" | "group" | "channel";
export type JobStatus = export type JobStatus =
| "pending" | "pending"
| "running" | "running"
@@ -43,6 +62,8 @@ export interface Chat {
is_contact: boolean; is_contact: boolean;
kind: ChatKind; kind: ChatKind;
last_date: string | null; last_date: string | null;
last_deleted: boolean;
last_message_id: number | null;
last_sender_id: number | null; last_sender_id: number | null;
last_text: string | null; last_text: string | null;
message_count: number; message_count: number;
@@ -194,6 +215,7 @@ export interface MessageView {
entities: EntityView[]; entities: EntityView[];
forward: ForwardView | null; forward: ForwardView | null;
has_media: boolean; has_media: boolean;
has_note: boolean;
inline_buttons: InlineButton[][]; inline_buttons: InlineButton[][];
is_animated_emoji: boolean; is_animated_emoji: boolean;
is_self_destruct: boolean; is_self_destruct: boolean;
@@ -222,8 +244,8 @@ 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" | "bot" | "group" | "channel";
export interface FeedChat { export interface FeedChat {
chat_id: number; chat_id: number;
@@ -237,6 +259,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 +303,40 @@ 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;
found: SearchFound | 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;
scanned_to: string | null;
terms: string[];
}
export interface SearchQuery {
chat_id?: number;
cursor?: string;
date_from?: string;
date_to?: string;
deleted?: boolean;
edited?: boolean;
everywhere?: boolean;
has_media?: boolean;
kind?: SearchChatKind;
limit?: number;
query?: string;
sender_id?: number;
} }
export interface MediaView { export interface MediaView {
@@ -2,6 +2,7 @@
import type { MessageView } from "#lib/api/types.js"; import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { accountName, peerName } from "#lib/format/peer.js"; import { accountName, 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 { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
@@ -16,10 +17,12 @@
function nameOf(id: number | null): string { function nameOf(id: number | null): string {
if (id === null) { if (id === null) {
return "Someone"; return t("service.someone");
} }
if (id === ownId) { if (id === ownId) {
return accounts.selected ? accountName(accounts.selected) : "You"; return accounts.selected
? accountName(accounts.selected)
: t("service.you");
} }
const peer = peers.get(id); const peer = peers.get(id);
return peer ? peerName(peer) : String(id); return peer ? peerName(peer) : String(id);
@@ -44,35 +47,39 @@
const selfJoin = const selfJoin =
members.length === 1 && members[0] === message.sender_id; members.length === 1 && members[0] === message.sender_id;
if (selfJoin) { if (selfJoin) {
return `${actor} joined the group`; return t("service.joined", { actor });
} }
const names = members.map(nameOf).join(", "); const names = members.map(nameOf).join(", ");
return names ? `${actor} added ${names}` : `${actor} joined the group`; return names
? t("service.added", { actor, names })
: t("service.joined", { actor });
} }
case "left_chat_members": case "left_chat_members":
return `${actor} left the group`; return t("service.left", { actor });
case "pinned_message": case "pinned_message":
return `${actor} pinned a message`; return t("service.pinned", { actor });
case "group_chat_created": case "group_chat_created":
return `${actor} created the group`; return t("service.groupCreated", { actor });
case "channel_chat_created": case "channel_chat_created":
return "Channel created"; return t("service.channelCreated");
case "phone_call_ended": case "phone_call_ended":
return service.duration === null return service.duration === null
? "Call ended" ? t("service.callEnded")
: `Call ended · ${durationOf(service.duration)}`; : t("service.callEndedAfter", {
duration: durationOf(service.duration),
});
case "phone_call_started": case "phone_call_started":
return "Call started"; return t("service.callStarted");
case "poll_option_added": case "poll_option_added":
return `${actor} added a poll option`; return t("service.pollOption", { actor });
case "new_chat_title": case "new_chat_title":
return `${actor} changed the group name`; return t("service.titleChanged", { actor });
case "new_chat_photo": case "new_chat_photo":
return `${actor} changed the group photo`; return t("service.photoChanged", { actor });
case "delete_chat_photo": case "delete_chat_photo":
return `${actor} removed the group photo`; return t("service.photoRemoved", { actor });
default: default:
return service.kind.replace(/_/g, " ") || "Service message"; return t("service.unknown");
} }
}); });
@@ -0,0 +1,44 @@
<script lang="ts">
import { Popover } from "bits-ui";
import ChatCalendar from "#lib/components/profile/ChatCalendar.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
anchor: HTMLElement | null;
chatId: number;
month: { month: number; year: number } | null;
open: boolean;
}
let { anchor, chatId, month, open = $bindable() }: Props = $props();
</script>
<Popover.Root bind:open>
<Popover.Portal>
<Popover.Content
aria-label={t("chat.calendar")}
class="bg-menu-content calendar-popover"
customAnchor={anchor}
side="bottom"
sideOffset={6}
>
{#key month}
<ChatCalendar
{chatId}
initial={month}
onjump={() => {
open = false;
}}
/>
{/key}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<style lang="scss">
:global(.calendar-popover) {
width: min(20rem, calc(100vw - 1.5rem));
padding: 0;
background-color: var(--color-background);
}
</style>
+37 -3
View File
@@ -29,8 +29,11 @@
const isDm = $derived(chatId > 0); const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId)); const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId)); const discovered = $derived(discover.get(chatId));
const DONE_DURATION = 2500;
let peer = $state<PeerView | null>(null); let peer = $state<PeerView | null>(null);
let backfilling = $state(false); let backfilling = $state(false);
let backfilled = $state(false);
async function backfill() { async function backfill() {
if (backfilling) { if (backfilling) {
@@ -39,7 +42,10 @@
backfilling = true; backfilling = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true);
toasts.success(t("chat.backfill.started")); backfilled = true;
setTimeout(() => {
backfilled = false;
}, DONE_DURATION);
} catch { } catch {
toasts.error(t("chat.backfill.failed")); toasts.error(t("chat.backfill.failed"));
} finally { } finally {
@@ -48,6 +54,10 @@
} }
function back() { function back() {
if (page.state.inApp) {
history.back();
return;
}
const target = new URL(page.url.href); const target = new URL(page.url.href);
target.pathname = "/app"; target.pathname = "/app";
goto(withoutMessage(target)); goto(withoutMessage(target));
@@ -175,6 +185,16 @@
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")} <ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
>{t("feed.changes")}</ContextMenuItem >{t("feed.changes")}</ContextMenuItem
> >
<ContextMenuItem icon="stats" onselect={() => ui.openPanel("presence")}
>{t("chat.analytics")}</ContextMenuItem
>
<ContextMenuItem
icon={backfilled ? "check" : "cloud-download"}
onselect={backfill}
>{backfilled
? t("chat.backfill.started")
: t("chat.backfill")}</ContextMenuItem
>
<ContextMenuItem <ContextMenuItem
icon="play-story" icon="play-story"
onselect={() => ui.openPanel("stories")} onselect={() => ui.openPanel("stories")}
@@ -199,6 +219,7 @@
</Button> </Button>
<Button <Button
aria-label={t("feed.changes")} aria-label={t("feed.changes")}
class="wide-only"
onclick={() => ui.openPanel("changes")} onclick={() => ui.openPanel("changes")}
round round
smaller smaller
@@ -216,14 +237,16 @@
<Icon name="stats" /> <Icon name="stats" />
</Button> </Button>
<Button <Button
aria-label={t("chat.backfill")} aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
class="wide-only"
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
round round
smaller smaller
title={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
variant="translucent" variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name={backfilled ? "check" : "cloud-download"} />
</Button> </Button>
</div> </div>
</header> </header>
@@ -275,6 +298,12 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
margin-left: auto; margin-left: auto;
@media (max-width: 600px) {
:global(.wide-only) {
display: none;
}
}
} }
.title { .title {
@@ -287,8 +316,13 @@
} }
.subtitle { .subtitle {
overflow: hidden;
display: block;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.online { &.online {
color: var(--color-primary); color: var(--color-primary);
@@ -76,6 +76,27 @@
} }
}); });
$effect(() => {
const id = activeChatId;
untrack(() => {
const index = list.findIndex((chat) => chat.chat_id === id);
if (index === -1 || viewport === null) {
return;
}
const row = viewport.querySelector<HTMLElement>(".Chat.selected");
if (row) {
row.scrollIntoView({ block: "nearest" });
return;
}
const top = index * rowHeight;
if (top < viewport.scrollTop) {
viewport.scrollTop = top;
} else if (top + rowHeight > viewport.scrollTop + viewport.clientHeight) {
viewport.scrollTop = top + rowHeight - viewport.clientHeight;
}
});
});
function measure(el: HTMLElement) { function measure(el: HTMLElement) {
({ scrollTop } = el); ({ scrollTop } = el);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) { if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
@@ -4,9 +4,11 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } 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 { online } from "#lib/stores/online.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -31,6 +33,14 @@
? t("chat.deletedAccount") ? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chat.chat_id })) : t("chat.fallbackTitle", { id: chat.chat_id }))
); );
const person = $derived(chat.chat_id > 0 && !chat.is_bot);
const isOnline = $derived(person && online.isOnline(chat.chat_id));
$effect(() => {
if (person) {
online.watch(chat.chat_id);
}
});
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat"); const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const showSender = $derived( const showSender = $derived(
@@ -74,12 +84,14 @@
class:selected class:selected
use:ripple use:ripple
> >
<Avatar <span class="avatar" class:online={isOnline}>
avatar={{ id: chat.chat_id, kind: avatarKind }} <Avatar
colorKey={chat.chat_id} avatar={{ id: chat.chat_id, kind: avatarKind }}
hasAvatar={chat.has_avatar} colorKey={chat.chat_id}
name={title} hasAvatar={chat.has_avatar}
/> name={title}
/>
</span>
<div class="info"> <div class="info">
<div class="info-row"> <div class="info-row">
<h3 class="title">{title}</h3> <h3 class="title">{title}</h3>
@@ -90,7 +102,13 @@
{#if senderPrefix} {#if senderPrefix}
<span class="sender">{senderPrefix}</span> <span class="sender">{senderPrefix}</span>
{/if} {/if}
{preview} {#if chat.last_deleted}
<span class="deleted-mark" title={t("chat.deletedPreview")}
><Icon name="delete" size="1rem" /></span
><s class="deleted-text">{preview}</s>
{:else}
{preview}
{/if}
</span> </span>
</div> </div>
</div> </div>
@@ -147,11 +165,15 @@
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
--online-ring: var(--color-chat-hover);
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
&.selected { &.selected {
--online-ring: var(--color-chat-active);
background-color: var(--color-chat-active); background-color: var(--color-chat-active);
.title, .title,
@@ -163,6 +185,37 @@
} }
} }
.avatar {
position: relative;
display: flex;
flex-shrink: 0;
&.online::after {
content: "";
position: absolute;
right: 0.0625rem;
bottom: 0.0625rem;
width: 0.875rem;
height: 0.875rem;
border: 2px solid var(--online-ring, var(--color-background));
border-radius: 50%;
background-color: var(--color-green);
}
}
.deleted-mark {
display: inline-flex;
margin-inline-end: 0.1875rem;
vertical-align: -0.1875rem;
}
.deleted-text {
text-decoration: line-through;
}
.info { .info {
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
+49 -2
View File
@@ -8,14 +8,16 @@
jumboEmojiCount, jumboEmojiCount,
linkHref, linkHref,
} from "#lib/format/entities.js"; } from "#lib/format/entities.js";
import { markText } from "#lib/format/search.js";
interface Props { interface Props {
entities: EntityView[]; entities: EntityView[];
marks?: string[];
own?: boolean; own?: boolean;
text: string; text: string;
} }
let { text, entities, own = false }: Props = $props(); let { text, entities, marks = [], own = false }: Props = $props();
const nodes = $derived(buildEntityTree(text, entities)); const nodes = $derived(buildEntityTree(text, entities));
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0); const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
@@ -32,13 +34,29 @@
)} )}
{#each items as node, i (i)} {#each items as node, i (i)}
{#if node.kind === "text"} {#if node.kind === "text"}
{node.text} {@render plain(node.text)}
{:else} {:else}
{@render entity(node)} {@render entity(node)}
{/if} {/if}
{/each} {/each}
{/snippet} {/snippet}
{#snippet plain(
value: string
)}
{#if marks.length === 0}
{value}
{:else}
{#each markText(value, marks) as segment, i (i)}
{#if segment.hit}
<mark class="found" class:own>{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}
{/if}
{/snippet}
{#snippet entity( {#snippet entity(
node: EntityNode node: EntityNode
)} )}
@@ -186,6 +204,35 @@
background-color: var(--color-primary-tint); background-color: var(--color-primary-tint);
} }
.found {
padding: 0;
border-radius: 0.25rem;
color: inherit;
background-color: transparent;
animation: found-flash 3s ease;
--found-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
&.own {
--found-color: color-mix(
in srgb,
var(--color-own-links) 35%,
transparent
);
}
}
@keyframes found-flash {
0%,
70% {
background-color: var(--found-color);
}
100% {
background-color: transparent;
}
}
.spoiler { .spoiler {
cursor: pointer; cursor: pointer;
+11 -2
View File
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { feed } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { page } from "$app/state";
interface Tab { interface Tab {
id: number | null; id: number | null;
@@ -10,7 +13,7 @@
} }
const tabs = $derived<Tab[]>([ const tabs = $derived<Tab[]>([
{ id: null, title: "All Chats" }, { id: null, title: t("folders.all") },
...folders.list.map((folder) => ({ ...folders.list.map((folder) => ({
id: folder.folder_id, id: folder.folder_id,
title: folder.title, title: folder.title,
@@ -92,8 +95,14 @@
</button> </button>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem
icon="recent"
onselect={() => feed.open(page.url, tab.id)}
>
{t("feed.folder")}
</ContextMenuItem>
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}> <ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
Политика захвата {t("settings.policy")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ForwardView } from "#lib/api/types.js"; import type { ForwardView } from "#lib/api/types.js";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
interface Props { interface Props {
@@ -14,20 +15,20 @@
); );
const name = $derived.by(() => { const name = $derived.by(() => {
if (forward.kind === "channel") { if (forward.kind === "channel") {
return forward.chat_title ?? "Channel"; return forward.chat_title ?? t("forward.channel");
} }
if (forward.kind === "hidden") { if (forward.kind === "hidden") {
return forward.from_name ?? "Hidden account"; return forward.from_name ?? t("forward.hidden");
} }
if (peer) { if (peer) {
return peerName(peer); return peerName(peer);
} }
return forward.from_name ?? "Unknown"; return forward.from_name ?? t("forward.unknown");
}); });
</script> </script>
<div class="ForwardHeader"> <div class="ForwardHeader">
<span class="label">Forwarded from</span> <span class="label">{t("forward.from")}</span>
<span class="name">{name}</span> <span class="name">{name}</span>
{#if forward.signature} {#if forward.signature}
<span class="signature">({forward.signature})</span> <span class="signature">({forward.signature})</span>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { tick } from "svelte";
import { chats } from "#lib/stores/chats.svelte.js";
import { search } from "#lib/stores/search.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const EDITABLE = "input, textarea, select, [contenteditable='true']";
const LAYERS = "[role='dialog'], [role='alertdialog'], [role='menu']";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
async function focusIn(selector: string) {
await tick();
document.querySelector<HTMLInputElement>(selector)?.focus();
}
async function focusSearch() {
if (ui.leftView === "settings") {
ui.closeSettings();
}
await focusIn("#LeftColumn .left-header input");
}
function stepChat(step: number) {
const { list } = chats;
const index = list.findIndex((chat) => chat.chat_id === chatId);
if (index === -1 && step < 0) {
return;
}
const target = index === -1 ? list[0] : list[index + step];
if (target) {
goto(`/app/${target.chat_id}`);
}
}
function closeTop(editable: HTMLElement | null) {
if (editable) {
if (editable.closest("#LeftColumn .left-header")) {
search.close();
}
editable.blur();
} else if (ui.rightPanel !== null) {
ui.closePanel();
} else if (ui.leftView === "settings") {
ui.closeSettings();
} else if (chatId !== null) {
goto("/app");
}
}
function onCommand(event: KeyboardEvent): boolean {
const key = event.key.toLowerCase();
if (key === "k") {
focusSearch();
return true;
}
if (key === "f" && chatId !== null) {
ui.openPanel("search");
focusIn("#RightColumn input");
return true;
}
return false;
}
function onPlain(event: KeyboardEvent): boolean {
if (event.key === "/" && !event.altKey) {
focusSearch();
return true;
}
if (
event.altKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
stepChat(event.key === "ArrowUp" ? -1 : 1);
return true;
}
return false;
}
function onkeydown(event: KeyboardEvent) {
if (event.defaultPrevented || document.querySelector(LAYERS)) {
return;
}
const target = event.target as HTMLElement | null;
const editable = target?.closest<HTMLElement>(EDITABLE) ?? null;
const modified = event.ctrlKey || event.metaKey;
if (event.key === "Escape") {
closeTop(editable);
return;
}
const handled = modified
? !event.altKey && onCommand(event)
: editable === null && onPlain(event);
if (handled) {
event.preventDefault();
}
}
</script>
<svelte:window {onkeydown} />
@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { InlineButton } from "#lib/api/types.js"; import type { InlineButton } from "#lib/api/types.js";
import { buttonHasMark } from "#lib/format/search.js";
import { safeHref } from "#lib/format/url.js"; import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
marks?: string[];
rows: InlineButton[][]; rows: InlineButton[][];
} }
let { rows }: Props = $props(); let { marks = [], rows }: Props = $props();
let copied = $state<string | null>(null); let copied = $state<string | null>(null);
let resetTimer: ReturnType<typeof setTimeout> | undefined; let resetTimer: ReturnType<typeof setTimeout> | undefined;
@@ -26,12 +29,14 @@
<div class="row"> <div class="row">
{#each row as button, colIndex (colIndex)} {#each row as button, colIndex (colIndex)}
{@const key = `${rowIndex}:${colIndex}`} {@const key = `${rowIndex}:${colIndex}`}
{@const lit = marks.length > 0 && buttonHasMark(button, marks)}
{#if button.kind === "url" && button.url} {#if button.kind === "url" && button.url}
<a <a
class="button" class="button"
href={safeHref(button.url)} href={safeHref(button.url)}
rel="noopener" rel="noopener"
target="_blank" target="_blank"
class:found={lit}
> >
<span class="label">{button.text}</span> <span class="label">{button.text}</span>
<span class="corner">↗</span> <span class="corner">↗</span>
@@ -41,13 +46,14 @@
class="button" class="button"
onclick={() => copy(key, button.data ?? "")} onclick={() => copy(key, button.data ?? "")}
type="button" type="button"
class:found={lit}
> >
<span class="label" <span class="label"
>{copied === key ? "Скопировано" : button.text}</span >{copied === key ? t("inline.copied") : button.text}</span
> >
</button> </button>
{:else} {:else}
<span class="button static"> <span class="button static" class:found={lit}>
<span class="label">{button.text}</span> <span class="label">{button.text}</span>
</span> </span>
{/if} {/if}
@@ -103,6 +109,27 @@
} }
} }
.found {
animation: found-flash 3s ease;
}
@keyframes found-flash {
0%,
70% {
background-color: color-mix(
in srgb,
var(--color-primary) 30%,
var(--color-message-reaction)
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
100% {
background-color: var(--color-message-reaction);
box-shadow: inset 0 0 0 1px transparent;
}
}
.label { .label {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
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 { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,7 +21,7 @@
await accounts.load(); await accounts.load();
await goto("/app"); await goto("/app");
} catch { } catch {
toasts.error("Invalid token"); toasts.error(t("login.invalid"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -33,16 +34,17 @@
<Icon name="lock" size="2.75rem" /> <Icon name="lock" size="2.75rem" />
</div> </div>
<h1>Beavergram</h1> <h1>Beavergram</h1>
<p class="subtitle">Enter your access token to continue.</p> <p class="subtitle">{t("login.subtitle")}</p>
<input <input
aria-label={t("login.token")}
autocomplete="current-password" autocomplete="current-password"
class="form-control" class="form-control"
placeholder="Access token" placeholder={t("login.token")}
type="password" type="password"
bind:value bind:value
> >
<Button disabled={busy || !value.trim()} loading={busy} type="submit"> <Button disabled={busy || !value.trim()} loading={busy} type="submit">
Log in {t("login.submit")}
</Button> </Button>
</form> </form>
</div> </div>
+29 -1
View File
@@ -5,10 +5,12 @@
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte"; import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { captureLine } from "#lib/format/capture.js";
import { accountName } from "#lib/format/peer.js"; import { accountName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { theme } from "#lib/stores/theme.svelte.js"; import { theme } from "#lib/stores/theme.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -23,6 +25,7 @@
const entries: Entry[] = [ const entries: Entry[] = [
{ icon: "eye", label: "settings.watches", panel: "watches" }, { icon: "eye", label: "settings.watches", panel: "watches" },
{ icon: "unmute", label: "settings.alerts", panel: "alerts" }, { icon: "unmute", label: "settings.alerts", panel: "alerts" },
{ icon: "note", label: "notes.title", panel: "notes" },
{ icon: "data", label: "settings.storage", panel: "storage" }, { icon: "data", label: "settings.storage", panel: "storage" },
{ icon: "play-story", label: "settings.stories", panel: "stories-all" }, { icon: "play-story", label: "settings.stories", panel: "stories-all" },
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" }, { icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
@@ -31,6 +34,12 @@
let adding = $state(false); let adding = $state(false);
const quiet = $derived(
capture.status !== null && !capture.status.live
? captureLine(capture.status, accounts.selected?.is_bot ?? false)
: null
);
function onkeydown(event: KeyboardEvent) { function onkeydown(event: KeyboardEvent) {
const match = DIGIT.exec(event.code); const match = DIGIT.exec(event.code);
if (!(event.altKey && match)) { if (!(event.altKey && match)) {
@@ -54,14 +63,18 @@
<span class="menu-trigger"> <span class="menu-trigger">
<Button <Button
{...props} {...props}
aria-label={t("menu.open")} aria-label={quiet ? `${t("menu.open")}. ${quiet}` : t("menu.open")}
round round
smaller smaller
title={quiet ?? undefined}
variant="translucent" variant="translucent"
> >
<Icon name="menu" /> <Icon name="menu" />
</Button> </Button>
<AlertBadge /> <AlertBadge />
{#if quiet}
<span class="capture-dot"></span>
{/if}
</span> </span>
{/snippet} {/snippet}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -142,6 +155,21 @@
flex-shrink: 0; flex-shrink: 0;
} }
.capture-dot {
pointer-events: none;
position: absolute;
right: 0.125rem;
bottom: 0.125rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-error);
box-shadow: 0 0 0 2px var(--color-background);
}
:global(.main-menu) { :global(.main-menu) {
width: 17rem; width: 17rem;
max-height: calc(100dvh - 5rem); max-height: calc(100dvh - 5rem);
+35 -19
View File
@@ -15,7 +15,9 @@
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 { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -48,6 +50,7 @@
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let token = 0; let token = 0;
const queued = createFlash<number>();
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : ""); const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
const isImage = $derived(mime.startsWith("image/") || kind === "photo"); const isImage = $derived(mime.startsWith("image/") || kind === "photo");
@@ -61,7 +64,9 @@
const canSave = $derived( const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready") currentMediaId !== null && (fileOnly || result?.state === "ready")
); );
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа"); const title = $derived(
fileName ?? mediaKindLabel(kind) ?? t("media.kind.other")
);
const caption = $derived(items[index]?.caption); const caption = $derived(items[index]?.caption);
function revoke() { function revoke() {
@@ -124,7 +129,7 @@
} }
} catch { } catch {
if (current === token) { if (current === token) {
toasts.error("Failed to load media"); toasts.error(t("media.loadFailed"));
} }
} finally { } finally {
if (current === token) { if (current === token) {
@@ -137,11 +142,12 @@
if (messageId === null) { if (messageId === null) {
return; return;
} }
const target = messageId;
try { try {
await fetchMedia(chatId, messageId); await fetchMedia(chatId, target);
toasts.success("Скачивание поставлено в очередь"); queued.show(target);
} catch { } catch {
toasts.error("Не удалось поставить в очередь"); toasts.error(t("media.queueFailed"));
} }
} }
@@ -153,7 +159,7 @@
try { try {
await downloadMedia(currentMediaId); await downloadMedia(currentMediaId);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("ui.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -225,7 +231,7 @@
{#if canSave} {#if canSave}
{#if ontrash} {#if ontrash}
<button <button
aria-label="В корзину" aria-label={t("storage.toTrash")}
class="media-action" class="media-action"
onclick={trash} onclick={trash}
type="button" type="button"
@@ -239,7 +245,7 @@
})} })}
<button <button
{...props} {...props}
aria-label="Скачать файл" aria-label={t("ui.downloadFile")}
class="media-action" class="media-action"
onclick={save} onclick={save}
type="button" type="button"
@@ -249,15 +255,15 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}>
Скачать файл {t("ui.downloadFile")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке {t("ui.shareLink")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<button <button
aria-label="Доступ по ссылке" aria-label={t("ui.shareLink")}
class="media-action" class="media-action"
onclick={share} onclick={share}
type="button" type="button"
@@ -265,7 +271,7 @@
<Icon name="allow-share" size="1.375rem" /> <Icon name="allow-share" size="1.375rem" />
</button> </button>
{/if} {/if}
<Dialog.Close aria-label="Закрыть" class="media-close"> <Dialog.Close aria-label={t("common.close")} class="media-close">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -276,7 +282,7 @@
{#if loading} {#if loading}
<Spinner color="white" /> <Spinner color="white" />
{:else if result?.state === "ready" && isImage} {:else if result?.state === "ready" && isImage}
<img alt={kind} class="media-image" src={result.url}> <img alt={title} class="media-image" src={result.url}>
{:else if result?.state === "ready" && isVideo} {:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
@@ -301,24 +307,29 @@
{#if currentMediaId !== null} {#if currentMediaId !== null}
<button class="media-download" onclick={save} type="button"> <button class="media-download" onclick={save} type="button">
<Icon name="download" /> <Icon name="download" />
Скачать файл {t("ui.downloadFile")}
</button> </button>
{/if} {/if}
</div> </div>
{:else if result?.state === "not-downloaded"} {:else if result?.state === "not-downloaded"}
<div class="media-message"> <div class="media-message">
<p>Файл ещё не скачан в архив.</p> <p>{t("media.notDownloaded")}</p>
<Button fluid onclick={queueFetch} variant="primary"> <Button fluid onclick={queueFetch} variant="primary">
Скачать в архив {#if queued.current === messageId}
<Icon name="check" />
{t("chat.media.queued")}
{:else}
{t("media.downloadToArchive")}
{/if}
</Button> </Button>
</div> </div>
{:else if result?.state === "missing"} {:else if result?.state === "missing"}
<p class="media-message">Файл не найден.</p> <p class="media-message">{t("media.missing")}</p>
{/if} {/if}
</div> </div>
{#if hasNav} {#if hasNav}
<button <button
aria-label="Previous" aria-label={t("ui.previous")}
class="media-nav prev" class="media-nav prev"
disabled={index === 0} disabled={index === 0}
onclick={() => step(-1)} onclick={() => step(-1)}
@@ -327,7 +338,7 @@
<Icon name="previous" size="1.75rem" /> <Icon name="previous" size="1.75rem" />
</button> </button>
<button <button
aria-label="Next" aria-label={t("ui.next")}
class="media-nav next" class="media-nav next"
disabled={index === items.length - 1} disabled={index === items.length - 1}
onclick={() => step(1)} onclick={() => step(1)}
@@ -397,6 +408,11 @@
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-white); color: var(--color-white);
@media (max-width: 600px) {
top: auto;
bottom: 1.25rem;
}
} }
:global(.media-close) { :global(.media-close) {
+110 -57
View File
@@ -12,22 +12,29 @@
import Reactions from "#lib/components/Reactions.svelte"; import Reactions from "#lib/components/Reactions.svelte";
import ReplyHeader from "#lib/components/ReplyHeader.svelte"; import ReplyHeader from "#lib/components/ReplyHeader.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte"; import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import SavedItem from "#lib/components/SavedItem.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import CopyItem from "#lib/components/ui/CopyItem.svelte";
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 { buttonHasMark, hasMark } 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 { notes } from "#lib/stores/notes.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 { savedUi } from "#lib/stores/saved.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;
firstInGroup: boolean; firstInGroup: boolean;
highlighted: boolean; highlight: string[] | null;
isGroupChat: boolean; isGroupChat: boolean;
lastInGroup: boolean; lastInGroup: boolean;
message: MessageView; message: MessageView;
@@ -43,7 +50,7 @@
isGroupChat, isGroupChat,
firstInGroup, firstInGroup,
lastInGroup, lastInGroup,
highlighted, highlight,
animate, animate,
onjump, onjump,
onmedia, onmedia,
@@ -51,6 +58,14 @@
}: Props = $props(); }: Props = $props();
const deleted = $derived(message.deleted_at !== null); const deleted = $derived(message.deleted_at !== null);
const marks = $derived(highlight ?? []);
const markedInside = $derived(
marks.length > 0 &&
(hasMark(message.text, marks) ||
message.inline_buttons.some((row) =>
row.some((button) => buttonHasMark(button, marks))
))
);
const hasText = $derived(Boolean(message.text)); const hasText = $derived(Boolean(message.text));
const special = $derived( const special = $derived(
Boolean( Boolean(
@@ -59,27 +74,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) =>
@@ -114,16 +149,15 @@
onjump(message.reply.message_id); onjump(message.reply.message_id);
} }
} }
async function copyText() { function messageLink(): string {
await navigator.clipboard.writeText(message.text ?? ""); return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
toasts.success("Текст скопирован");
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success("ID скопирован");
} }
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);
} }
} }
@@ -139,7 +173,7 @@
data-message-id={message.message_id} data-message-id={message.message_id}
class:deleted class:deleted
class:first-in-group={firstInGroup} class:first-in-group={firstInGroup}
class:highlighted class:highlighted={highlight !== null && !markedInside}
class:last-in-group={lastInGroup} class:last-in-group={lastInGroup}
class:own class:own
class:with-avatar={isGroupChat} class:with-avatar={isGroupChat}
@@ -155,7 +189,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}
@@ -182,12 +219,6 @@
{#if message.reply} {#if message.reply}
<ReplyHeader {onjump} reply={message.reply} /> <ReplyHeader {onjump} reply={message.reply} />
{/if} {/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{/if}
{#if message.poll} {#if message.poll}
<Poll poll={message.poll} /> <Poll poll={message.poll} />
{/if} {/if}
@@ -206,19 +237,26 @@
{own} {own}
/> />
{:else if message.has_media} {:else if message.has_media}
<MessageMedia {message} onopen={() => onmedia(0)} {own} /> <MessageMedia
items={menu}
{message}
onopen={() => onmedia(0)}
{onversions}
{own}
/>
{/if} {/if}
{/if} {/if}
{#if hasText} {#if hasText}
<div class="text"> <div class="text">
<EntityText <EntityText
entities={message.entities} entities={message.entities}
{marks}
{own} {own}
text={message.text ?? ""} text={message.text ?? ""}
/> />
</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,18 +271,23 @@
{#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}
noted={notes.has(message)}
onnote={openAnnotations}
{onversions}
{own}
/>
{#if message.inline_buttons.length} {#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} /> <InlineButtons {marks} rows={message.inline_buttons} />
{/if} {/if}
{#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 +306,63 @@
<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 savedUi.available && !savedUi.isSavedChat(message.chat_id)}
<SavedItem
chatId={message.chat_id}
copy={Boolean(message.deleted_at || message.is_self_destruct)}
messageId={message.message_id}
/>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
label={t("chat.menu.copyLink")}
text={messageLink}
/>
{#if hasText} {#if hasText}
<ContextMenuItem icon="copy" onselect={copyText} <CopyItem
>Копировать текст</ContextMenuItem done={t("chat.menu.copied")}
> icon="copy"
label={t("thread.copyText")}
text={() => message.text ?? ""}
/>
{/if} {/if}
<ContextMenuItem icon="hashtag" onselect={copyId} <CopyItem
>Копировать ID</ContextMenuItem done={t("chat.menu.copied")}
> icon="hashtag"
label={t("thread.copyId")}
text={() => String(message.message_id)}
/>
{#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}
@@ -423,17 +487,6 @@
text-decoration: line-through; text-decoration: line-through;
} }
.deleted-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
}
.detail-chip { .detail-chip {
display: inline-flex; display: inline-flex;
+149 -21
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { tick, untrack } from "svelte"; import { tick, untrack } from "svelte";
import type { CaptureGap } from "#lib/api/capture.js";
import { listMessages } from "#lib/api/endpoints.js"; import { listMessages } from "#lib/api/endpoints.js";
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js"; import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
import type { LiveEvent, MessageView } from "#lib/api/types.js"; import type { LiveEvent, MessageView } from "#lib/api/types.js";
import ActionMessage from "#lib/components/ActionMessage.svelte"; import ActionMessage from "#lib/components/ActionMessage.svelte";
import CalendarPopover from "#lib/components/CalendarPopover.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte"; import MediaViewer from "#lib/components/MediaViewer.svelte";
import MessageBubble from "#lib/components/MessageBubble.svelte"; import MessageBubble from "#lib/components/MessageBubble.svelte";
import MessageVersions from "#lib/components/MessageVersions.svelte"; import MessageVersions from "#lib/components/MessageVersions.svelte";
@@ -11,9 +13,12 @@
import TrackChat from "#lib/components/TrackChat.svelte"; import TrackChat from "#lib/components/TrackChat.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 { formatDay } from "#lib/format/datetime.js"; import { formatDay, formatSpanRange } 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 { capture } from "#lib/stores/capture.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";
@@ -32,6 +37,8 @@
const STICK_OFFSET = 9; const STICK_OFFSET = 9;
const IDLE_DELAY = 1500; const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360; const MAX_MESSAGES = 360;
const SETTLE_DELAY = 400;
const HIGHLIGHT_MS = 3000;
let messages = $state<MessageView[]>([]); let messages = $state<MessageView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -43,6 +50,7 @@
let suppressAppear = $state(false); let suppressAppear = $state(false);
let scrolling = $state(false); let scrolling = $state(false);
let stuckDay = $state<string | null>(null); let stuckDay = $state<string | null>(null);
let passedDays = $state.raw(new Set<string>());
let idleTimer: ReturnType<typeof setTimeout> | null = null; let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false); let mediaOpen = $state(false);
@@ -50,16 +58,25 @@
let mediaIndex = $state(0); let mediaIndex = $state(0);
let versionsOpen = $state(false); let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null); let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null); let highlight = $state<{ id: number; marks: string[] } | null>(null);
let calendarOpen = $state(false);
let calendarAnchor = $state<HTMLElement | null>(null);
let calendarMonth = $state<{ month: number; year: number } | null>(null);
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;
daySeparator: string | null; daySeparator: string | null;
firstInGroup: boolean; firstInGroup: boolean;
gapsAfter: CaptureGap[];
gapsBefore: CaptureGap[];
lastInGroup: boolean; lastInGroup: boolean;
message: MessageView; message: MessageView;
own: boolean; own: boolean;
@@ -69,6 +86,27 @@
return new Date(iso).toDateString(); return new Date(iso).toDateString();
} }
const gapSpans = $derived(
capture.gaps.map((gap) => ({
day: dayKey(gap.start),
end: Date.parse(gap.end),
gap,
start: Date.parse(gap.start),
}))
);
function gapsBetween(
prev: MessageView | undefined,
message: MessageView
): typeof gapSpans {
if (!prev || gapSpans.length === 0) {
return [];
}
const from = Date.parse(prev.date);
const to = Date.parse(message.date);
return gapSpans.filter((span) => span.start >= from && span.end <= to);
}
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const out: Row[] = []; const out: Row[] = [];
for (let i = 0; i < messages.length; i += 1) { for (let i = 0; i < messages.length; i += 1) {
@@ -76,15 +114,28 @@
const prev = messages[i - 1]; const prev = messages[i - 1];
const next = messages[i + 1]; const next = messages[i + 1];
const day = dayKey(message.date); const day = dayKey(message.date);
const gaps = gapsBetween(prev, message);
const samePrevDay = prev ? dayKey(prev.date) === day : false; const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false; const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({ out.push({
dayKey: day, dayKey: day,
daySeparator: samePrevDay ? null : formatDay(message.date), daySeparator: samePrevDay ? null : formatDay(message.date),
firstInGroup: firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay, !prev ||
prev.sender_id !== message.sender_id ||
!samePrevDay ||
gaps.length > 0,
gapsAfter: gaps
.filter((span) => span.day === day)
.map((span) => span.gap),
gapsBefore: gaps
.filter((span) => span.day !== day)
.map((span) => span.gap),
lastInGroup: lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay, !next ||
next.sender_id !== message.sender_id ||
!sameNextDay ||
gapsBetween(message, next).length > 0,
message, message,
own: ownId !== null && message.sender_id === ownId, own: ownId !== null && message.sender_id === ownId,
}); });
@@ -179,7 +230,11 @@
return messages.find((m) => holds(m, messageId))?.message_id ?? null; return messages.find((m) => holds(m, messageId))?.message_id ?? null;
} }
function jumpToMessage(messageId: number, smooth = true) { function jumpToMessage(
messageId: number,
smooth = true,
marks: string[] = []
) {
const owner = ownerOf(messageId) ?? messageId; const owner = ownerOf(messageId) ?? messageId;
const target = container?.querySelector<HTMLElement>( const target = container?.querySelector<HTMLElement>(
`[data-message-id="${owner}"]` `[data-message-id="${owner}"]`
@@ -192,13 +247,13 @@
behavior: smooth ? "smooth" : "auto", behavior: smooth ? "smooth" : "auto",
block: "center", block: "center",
}); });
highlightId = owner; highlight = { id: owner, marks };
if (highlightTimer) { if (highlightTimer) {
clearTimeout(highlightTimer); clearTimeout(highlightTimer);
} }
highlightTimer = setTimeout(() => { highlightTimer = setTimeout(() => {
highlightId = null; highlight = null;
}, 1600); }, HIGHLIGHT_MS);
} }
function scrollToBottom() { function scrollToBottom() {
@@ -213,13 +268,14 @@
} }
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1; const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
const seps = container.querySelectorAll<HTMLElement>(".day-separator"); const seps = container.querySelectorAll<HTMLElement>(".day-separator");
let day: string | null = null; const stuck: string[] = [];
for (const sep of seps) { for (const sep of seps) {
if (sep.getBoundingClientRect().top <= limit) { if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) {
day = sep.dataset.day ?? null; stuck.push(sep.dataset.day);
} }
} }
stuckDay = day; stuckDay = stuck.pop() ?? null;
passedDays = new Set(stuck);
} }
async function loadInitial() { async function loadInitial() {
@@ -232,7 +288,7 @@
ui.clearJump(); ui.clearJump();
await loadAround(pending.messageId); await loadAround(pending.messageId);
await tick(); await tick();
jumpToMessage(pending.messageId, false); jumpToMessage(pending.messageId, false, pending.marks);
return; return;
} }
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
@@ -246,6 +302,7 @@
await tick(); await tick();
scrollToBottom(); scrollToBottom();
updateStuck(); updateStuck();
setTimeout(updateStuck, SETTLE_DELAY);
} catch { } catch {
toasts.error(t("thread.loadFailed")); toasts.error(t("thread.loadFailed"));
} finally { } finally {
@@ -388,6 +445,13 @@
} }
} }
function openCalendar(event: MouseEvent, iso: string) {
const date = new Date(iso);
calendarAnchor = event.currentTarget as HTMLElement;
calendarMonth = { month: date.getUTCMonth(), year: date.getUTCFullYear() };
calendarOpen = true;
}
function openMedia(message: MessageView, index: number) { function openMedia(message: MessageView, index: number) {
mediaItems = viewerItemsFrom(message.message_id, message.media); mediaItems = viewerItemsFrom(message.message_id, message.media);
mediaIndex = index; mediaIndex = index;
@@ -515,14 +579,14 @@
} }
} }
async function goTo(messageId: number) { async function goTo(messageId: number, marks: string[] = []) {
try { try {
const loaded = ownerOf(messageId) !== null; const loaded = ownerOf(messageId) !== null;
if (!loaded) { if (!loaded) {
await loadAround(messageId); await loadAround(messageId);
} }
await tick(); await tick();
jumpToMessage(messageId, loaded); jumpToMessage(messageId, loaded, marks);
} catch { } catch {
toasts.error(t("thread.loadFailed")); toasts.error(t("thread.loadFailed"));
} }
@@ -534,7 +598,7 @@
return; return;
} }
ui.clearJump(); ui.clearJump();
goTo(target.messageId); goTo(target.messageId, target.marks);
}); });
$effect(() => { $effect(() => {
@@ -575,22 +639,38 @@
<div class="loading-older"><Spinner /></div> <div class="loading-older"><Spinner /></div>
{/if} {/if}
{#each rows as row (row.message.message_id)} {#each rows as row (row.message.message_id)}
{#each row.gapsBefore as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.daySeparator} {#if row.daySeparator}
<div <div
class="day-separator" class="day-separator"
data-day={row.dayKey} data-day={row.dayKey}
class:idle={!scrolling && row.dayKey === stuckDay} class:idle={!scrolling && row.dayKey === stuckDay}
class:passed={passedDays.has(row.dayKey)}
> >
<span>{row.daySeparator}</span> <button
aria-label={t("chat.calendar")}
onclick={(event) => openCalendar(event, row.message.date)}
title={t("chat.calendar")}
type="button"
>
{row.daySeparator}
</button>
</div> </div>
{/if} {/if}
{#each row.gapsAfter as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.message.service} {#if row.message.service}
<ActionMessage message={row.message} onjump={goTo} /> <ActionMessage message={row.message} onjump={goTo} />
{:else} {:else}
<MessageBubble <MessageBubble
animate={!suppressAppear} animate={!suppressAppear}
firstInGroup={row.firstInGroup} firstInGroup={row.firstInGroup}
highlighted={highlightId === row.message.message_id} highlight={highlight?.id === row.message.message_id
? highlight.marks
: null}
{isGroupChat} {isGroupChat}
lastInGroup={row.lastInGroup} lastInGroup={row.lastInGroup}
message={row.message} message={row.message}
@@ -619,6 +699,20 @@
{/if} {/if}
</div> </div>
{#snippet gapLine(
gap: CaptureGap
)}
<div class="capture-gap">
<span>{t("chat.gap", { range: formatSpanRange(gap.start, gap.end) })}</span>
</div>
{/snippet}
<CalendarPopover
anchor={calendarAnchor}
{chatId}
month={calendarMonth}
bind:open={calendarOpen}
/>
<MediaViewer <MediaViewer
{chatId} {chatId}
items={mediaItems} items={mediaItems}
@@ -708,10 +802,15 @@
justify-content: center; justify-content: center;
margin: 0.75rem 0; margin: 0.75rem 0;
span { button {
pointer-events: auto;
cursor: pointer;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border: 0;
border-radius: 1rem; border-radius: 1rem;
font: inherit;
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-white); color: var(--color-white);
@@ -719,10 +818,39 @@
background-color: var(--color-default-shadow); background-color: var(--color-default-shadow);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
} }
&.idle span { &.idle button,
&.passed button {
pointer-events: none;
opacity: 0; opacity: 0;
} }
} }
.capture-gap {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
&::before,
&::after {
content: "";
flex: 1;
border-top: 1px dashed var(--color-borders);
}
span {
flex-shrink: 0;
}
}
</style> </style>
+82 -36
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ContextMenu as Menu } from "bits-ui";
import type { Snippet } from "svelte";
import { visible } from "#lib/actions/visible.js"; import { visible } from "#lib/actions/visible.js";
import { downloadMedia } from "#lib/api/download.js"; import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia } from "#lib/api/endpoints.js"; import { fetchMedia } from "#lib/api/endpoints.js";
@@ -14,24 +16,25 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte"; import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import VideoNote from "#lib/components/media/VideoNote.svelte"; import VideoNote from "#lib/components/media/VideoNote.svelte";
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte"; import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.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 { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.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";
interface Props { interface Props {
items?: Snippet;
message: MessageView; message: MessageView;
onopen: () => void; onopen: () => void;
onversions?: () => void;
own: boolean; own: boolean;
} }
let { message, onopen, own }: Props = $props(); let { items, message, onopen, onversions, own }: Props = $props();
const POLL_TRIES = 5; const POLL_TRIES = 5;
const POLL_DELAY = 3000; const POLL_DELAY = 3000;
@@ -39,6 +42,7 @@
let loaded = $state(false); let loaded = $state(false);
let media = $state<InlineMedia | null>(null); let media = $state<InlineMedia | null>(null);
let queuing = $state(false); let queuing = $state(false);
let queued = $state(false);
let saving = $state(false); let saving = $state(false);
const ref = $derived(message.media[0] ?? null); const ref = $derived(message.media[0] ?? null);
@@ -71,7 +75,10 @@
const storedId = $derived( const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
); );
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл"); const fileName = $derived(
ref?.file_name ?? mediaKindLabel(kind) ?? t("chat.media.file")
);
const waiting = $derived(queuing || queued);
function delay(ms: number): Promise<void> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -101,16 +108,16 @@
} }
async function queue() { async function queue() {
if (queuing) { if (waiting) {
return; return;
} }
queuing = true; queuing = true;
try { try {
await fetchMedia(message.chat_id, message.message_id); await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь"); queued = true;
poll(); poll();
} catch { } catch {
toasts.error("Не удалось поставить в очередь"); toasts.error(t("chat.media.queueFailed"));
} finally { } finally {
queuing = false; queuing = false;
} }
@@ -124,7 +131,7 @@
try { try {
await downloadMedia(storedId); await downloadMedia(storedId);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("chat.media.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -145,7 +152,7 @@
{#if message.is_self_destruct} {#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button"> <button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" /> <Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span> <span>{t("chat.media.selfDestruct")}</span>
</button> </button>
{:else if asFile && ref} {:else if asFile && ref}
<FileChip media={ref} {own} /> <FileChip media={ref} {own} />
@@ -160,16 +167,21 @@
url={ready.url} url={ready.url}
/> />
{:else if ready && kind === "video_note"} {:else if ready && kind === "video_note"}
<VideoNote transcript={ready.transcript} url={ready.url} /> <VideoNote
chatId={message.chat_id}
messageId={message.message_id}
transcript={ready.transcript}
url={ready.url}
/>
{:else if ready && kind === "audio"} {:else if ready && kind === "audio"}
<AudioFile {own} title={fileName} url={ready.url} /> <AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage} {:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}> <img alt={t("chat.media.photo")} src={ready.url}>
</button> </button>
{:else if ready && isStaticSticker} {:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button"> <button class="media-sticker" onclick={onopen} type="button">
<img alt="sticker" src={ready.url}> <img alt={fileName} src={ready.url}>
</button> </button>
{:else if ready && isVideoSticker} {:else if ready && isVideoSticker}
<video <video
@@ -203,48 +215,82 @@
<Icon name="document" size="1.25rem" /> <Icon name="document" size="1.25rem" />
<span>{label}</span> <span>{label}</span>
</button> </button>
{:else if media?.state === "not-downloaded" && kind === "voice"}
<VoiceMessage
chatId={message.chat_id}
length={ref?.duration}
messageId={message.message_id}
ondownload={queue}
{own}
transcript={media.transcript}
url={null}
{waiting}
/>
{:else if media?.state === "not-downloaded" && kind === "video_note"}
<VideoNote
chatId={message.chat_id}
length={ref?.duration}
messageId={message.message_id}
ondownload={queue}
transcript={media.transcript}
url={null}
{waiting}
/>
{:else if media?.state === "not-downloaded" && vk !== "other"} {:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button"> <button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" /> <Icon name={waiting ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Видео" : "Фото"}</span> <span
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small> >{vk === "video"
? t("chat.media.video")
: t("chat.media.photo")}</span
>
<small
>{waiting
? t("chat.media.queued")
: t("chat.media.tapToDownload")}</small
>
</button> </button>
{:else if media?.state === "not-downloaded"} {:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button"> <button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" /> <Icon name={waiting ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span> <span
>{waiting
? t("chat.media.queued")
: t("chat.media.download", { kind: label })}</span
>
</button> </button>
{:else} {:else}
<button class="media-chip" onclick={onopen} type="button"> <button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" /> <Icon name="photo" size="1.25rem" />
<span>Медиа</span> <span>{t("chat.media")}</span>
</button> </button>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen} <ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem >{t("chat.media.fullscreen")}</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
> >
{#if onversions}
<ContextMenuItem icon="recent" onselect={onversions}
>{t("chat.media.versions")}</ContextMenuItem
>
{/if}
{#if storedId === null} {#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}> <ContextMenuItem icon="cloud-download" onselect={queue}
Скачать в архив >{t("chat.media.queue")}</ContextMenuItem
</ContextMenuItem> >
{:else} {:else}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}
Скачать файл >{t("chat.media.save")}</ContextMenuItem
</ContextMenuItem> >
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}
Доступ по ссылке >{t("chat.media.share")}</ContextMenuItem
</ContextMenuItem> >
{/if}
{#if items}
<Menu.Separator class="bg-menu-separator" />
{@render items()}
{/if} {/if}
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
+126 -19
View File
@@ -1,30 +1,86 @@
<script lang="ts"> <script lang="ts">
import type { MessageView } from "#lib/api/types.js"; import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatTime } from "#lib/format/datetime.js"; import {
formatExact,
formatStamp,
formatTime,
} from "#lib/format/datetime.js";
import { readTitle } from "#lib/format/read.js"; import { readTitle } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
message: MessageView; message: MessageView;
noted?: boolean;
onnote?: () => void;
onversions?: () => void; onversions?: () => void;
own?: boolean; own?: boolean;
} }
let { message, onversions, own = false }: Props = $props(); let {
message,
noted = false,
onnote,
onversions,
own = false,
}: Props = $props();
const TIP_DURATION = 3000;
let tip = $state(false);
let tipTimer: ReturnType<typeof setTimeout> | undefined;
const exact = $derived(formatExact(message.date));
function toggleTip() {
tip = !tip;
clearTimeout(tipTimer);
if (tip) {
tipTimer = setTimeout(() => {
tip = false;
}, TIP_DURATION);
}
}
</script> </script>
<span class="MessageMeta"> <span class="MessageMeta">
{#if message.edited_at} {#if noted}
<button <button
class="edited" aria-label={t("chat.note")}
onclick={onversions} class="meta-button note"
title="View edit history" onclick={onnote}
title={t("chat.note")}
type="button" type="button"
> >
edited <Icon name="note" size="1rem" />
</button> </button>
{/if} {/if}
<span class="time">{formatTime(message.date)}</span> {#if message.deleted_at}
<span class="deleted" title={formatExact(message.deleted_at)}>
{t("chat.deletedAt", { when: formatStamp(message.deleted_at) })}
</span>
{/if}
{#if message.edited_at}
<button
class="meta-button edited"
onclick={onversions}
title={t("chat.editedAt", { when: formatExact(message.edited_at) })}
type="button"
>
{t("chat.edited")}
</button>
{/if}
<button
class="meta-button time"
onclick={toggleTip}
title={exact}
type="button"
>
{formatTime(message.date)}
</button>
{#if tip}
<span class="tip" role="status">{exact}</span>
{/if}
{#if own} {#if own}
<span class="ticks" title={readTitle(message)}> <span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" /> <Icon name={message.read ? "message-read" : "check"} size="1rem" />
@@ -34,6 +90,8 @@
<style lang="scss"> <style lang="scss">
.MessageMeta { .MessageMeta {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
@@ -47,21 +105,70 @@
user-select: none; user-select: none;
} }
.time { .meta-button {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
border-radius: 0.25rem;
font: inherit;
color: inherit;
white-space: nowrap; white-space: nowrap;
background: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.note {
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.edited,
.deleted {
font-style: italic;
white-space: nowrap;
}
.deleted::after {
content: "·";
margin-inline-start: 0.25rem;
font-style: normal;
}
.tip {
pointer-events: none;
position: absolute;
right: 0;
bottom: calc(100% + 0.375rem);
z-index: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-style: normal;
color: var(--color-white);
white-space: nowrap;
background-color: var(--color-toast-background);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
} }
.ticks { .ticks {
display: inline-flex; display: inline-flex;
} }
.edited {
cursor: pointer;
padding: 0;
border: 0;
font-size: 0.75rem;
font-style: italic;
color: inherit;
background: transparent;
}
</style> </style>
@@ -6,6 +6,7 @@
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 { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -33,7 +34,7 @@
versions = text; versions = text;
mediaVersions = media; mediaVersions = media;
}) })
.catch(() => toasts.error("Failed to load edit history")) .catch(() => toasts.error(t("versions.failed")))
.finally(() => { .finally(() => {
loading = false; loading = false;
}); });
@@ -46,8 +47,8 @@
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Edit history</Dialog.Title> <Dialog.Title class="dialog-title">{t("versions.title")}</Dialog.Title>
<Dialog.Close aria-label="Close" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -55,11 +56,11 @@
{#if loading} {#if loading}
<div class="centered"><Spinner /></div> <div class="centered"><Spinner /></div>
{:else if versions.length === 0 && mediaVersions.length === 0} {:else if versions.length === 0 && mediaVersions.length === 0}
<p class="empty">No versions recorded.</p> <p class="empty">{t("versions.none")}</p>
{:else} {:else}
{#if mediaVersions.length > 0} {#if mediaVersions.length > 0}
<div class="media-versions"> <div class="media-versions">
<span class="media-label">Media versions</span> <span class="media-label">{t("versions.media")}</span>
<div class="media-strip"> <div class="media-strip">
{#each mediaVersions as media (media.id)} {#each mediaVersions as media (media.id)}
<MediaVersionThumb version={media} /> <MediaVersionThumb version={media} />
@@ -70,12 +71,14 @@
{#each versions as version, index (version.observed_at)} {#each versions as version, index (version.observed_at)}
<div class="version"> <div class="version">
<div class="version-meta"> <div class="version-meta">
<span class="version-label">Version {index + 1}</span> <span class="version-label"
>{t("versions.version", { number: index + 1 })}</span
>
<span class="version-date" <span class="version-date"
>{formatFull(version.observed_at)}</span >{formatFull(version.observed_at)}</span
> >
</div> </div>
<div class="version-text">{version.text ?? "(no text)"}</div> <div class="version-text">{version.text ?? t("feed.noText")}</div>
</div> </div>
{/each} {/each}
{/if} {/if}
+3 -2
View File
@@ -3,6 +3,7 @@
import type { PinnedView } from "#lib/api/types.js"; import type { PinnedView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { mediaKindLabel } from "#lib/format/media.js"; import { mediaKindLabel } from "#lib/format/media.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";
interface Props { interface Props {
@@ -19,7 +20,7 @@
? (pinned.text ?? ? (pinned.text ??
(pinned.media_kind (pinned.media_kind
? mediaKindLabel(pinned.media_kind) ? mediaKindLabel(pinned.media_kind)
: "Pinned message")) : t("pinned.message")))
: "" : ""
); );
@@ -56,7 +57,7 @@
> >
<Icon name="pin" size="1.125rem" /> <Icon name="pin" size="1.125rem" />
<span class="body"> <span class="body">
<span class="label">Pinned message</span> <span class="label">{t("pinned.message")}</span>
<span class="preview">{preview}</span> <span class="preview">{preview}</span>
</span> </span>
</button> </button>
+10 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte"; import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte"; import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
import NotesPanel from "#lib/components/annotations/NotesPanel.svelte";
import BotsPanel from "#lib/components/bots/BotsPanel.svelte"; import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
import StoragePanel from "#lib/components/disk/StoragePanel.svelte"; import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte"; import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
@@ -25,6 +26,7 @@
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.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 { notes } from "#lib/stores/notes.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { storageUi } from "#lib/stores/storage.svelte.js"; import { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -37,6 +39,7 @@
changes: "feed.changes", changes: "feed.changes",
jobs: "settings.jobs", jobs: "settings.jobs",
links: "panel.links", links: "panel.links",
notes: "notes.title",
policy: "settings.policy", policy: "settings.policy",
presence: "chat.analytics", presence: "chat.analytics",
profile: "chat.profile", profile: "chat.profile",
@@ -49,7 +52,6 @@
"storage-trash": "panel.trash", "storage-trash": "panel.trash",
stories: "settings.stories", stories: "settings.stories",
"stories-all": "panel.allStories", "stories-all": "panel.allStories",
versions: "panel.versions",
watches: "settings.watches", watches: "settings.watches",
webhooks: "watches.webhooks", webhooks: "watches.webhooks",
}; };
@@ -65,6 +67,9 @@
if (ui.rightPanel === null) { if (ui.rightPanel === null) {
return ""; return "";
} }
if (ui.rightPanel === "notes" && notes.tag !== null) {
return t("notes.tag", { tag: notes.tag });
}
const chat = const chat =
ui.rightPanel === "storage-chat" && storageUi.chatId !== null ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId) ? chats.byId(storageUi.chatId)
@@ -139,6 +144,10 @@
<AlertsPanel /> <AlertsPanel />
{:else if ui.rightPanel === "annotations"} {:else if ui.rightPanel === "annotations"}
<AnnotationsPanel /> <AnnotationsPanel />
{:else if ui.rightPanel === "notes"}
{#key accounts.selectedId}
<NotesPanel />
{/key}
{:else if ui.rightPanel === "shares"} {:else if ui.rightPanel === "shares"}
<SharesPanel /> <SharesPanel />
{:else if ui.rightPanel === "bots"} {:else if ui.rightPanel === "bots"}
@@ -0,0 +1,464 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { savedUi } from "#lib/stores/saved.svelte.js";
const VOICE_NAME = /\.(ogg|oga|opus)$/i;
const MAX_INPUT_HEIGHT = 240;
let text = $state("");
let sending = $state(false);
let failure = $state<string | null>(null);
let file = $state<File | null>(null);
let caption = $state("");
let voice = $state(false);
let picker = $state<HTMLInputElement>();
let input = $state<HTMLTextAreaElement>();
const dialogOpen = $derived(file !== null);
const isAudio = $derived(
file !== null &&
(file.type.startsWith("audio/") || VOICE_NAME.test(file.name))
);
const preview = $derived(
file?.type.startsWith("image/") ? URL.createObjectURL(file) : null
);
$effect(() => {
const url = preview;
return () => {
if (url) {
URL.revokeObjectURL(url);
}
};
});
function fit() {
if (!input) {
return;
}
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`;
}
async function sendText() {
const message = text.trim();
if (!message || sending) {
return;
}
sending = true;
const result = await savedUi.send({ text: message });
sending = false;
failure = result.ok ? null : t(result.label);
if (result.ok) {
text = "";
requestAnimationFrame(fit);
}
}
async function sendFile() {
if (!file || sending) {
return;
}
sending = true;
const result = await savedUi.send({
file,
text: caption.trim(),
voice: isAudio && voice,
});
sending = false;
failure = result.ok ? null : t(result.label);
if (result.ok) {
file = null;
caption = "";
}
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
sendText();
}
}
function onpick() {
const picked = picker?.files?.[0] ?? null;
if (picker) {
picker.value = "";
}
if (!picked) {
return;
}
file = picked;
caption = text.trim();
text = "";
voice = VOICE_NAME.test(picked.name) || picked.type === "audio/ogg";
requestAnimationFrame(fit);
}
function onOpenChange(open: boolean) {
if (!(open || sending)) {
failure = null;
if (!text) {
text = caption;
}
file = null;
caption = "";
requestAnimationFrame(fit);
}
}
</script>
<div class="SavedComposer">
{#if failure && !file}
<p class="failure">{failure}</p>
{/if}
<div class="field">
<textarea
aria-label={t("saved.message")}
oninput={fit}
{onkeydown}
placeholder={t("saved.message")}
rows="1"
bind:this={input}
bind:value={text}
></textarea>
<button
aria-label={t("saved.attach")}
class="attach"
onclick={() => picker?.click()}
type="button"
>
<Icon name="attach" size="1.5rem" />
</button>
<input hidden onchange={onpick} type="file" bind:this={picker}>
</div>
<Button
aria-label={t("saved.send")}
class="send"
disabled={!text.trim()}
loading={sending && !file}
onclick={sendText}
round
>
<Icon name="send" size="1.5rem" />
</Button>
</div>
<Dialog.Root {onOpenChange} open={dialogOpen}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">{t("saved.sendFile")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if preview}
<img alt={file?.name} class="preview" src={preview}>
{/if}
<div class="file">
<span class="tile">
<Icon name={isAudio ? "microphone" : "document"} size="1.5rem" />
</span>
<span class="meta">
<span class="name">{file?.name}</span>
<span class="size">{formatBytes(file?.size ?? null)}</span>
</span>
</div>
<textarea
aria-label={t("saved.caption")}
class="caption"
placeholder={t("saved.caption")}
rows="2"
bind:value={caption}
></textarea>
{#if isAudio}
<button
aria-checked={voice}
class="toggle-row"
onclick={() => {
voice = !voice;
}}
role="switch"
type="button"
>
<span class="label">
<span>{t("saved.asVoice")}</span>
<small>{t("saved.asVoiceHint")}</small>
</span>
<span class="switch" class:on={voice}>
<span class="knob"></span>
</span>
</button>
{/if}
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div>
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
props,
})}
<Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet}
</Dialog.Close>
<Button disabled={sending} loading={sending} onclick={sendFile} pill>
{t("saved.send")}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.SavedComposer {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem max(1rem, calc((100% - var(--messages-container-width)) / 2))
1.25rem;
:global(.send) {
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
}
}
.failure {
flex-basis: 100%;
margin: 0;
padding: 0 0.25rem;
font-size: 0.875rem;
color: var(--color-error);
}
.dialog-body .failure {
margin-top: 0.75rem;
padding: 0;
}
.field {
display: flex;
flex: 1;
align-items: flex-end;
min-width: 0;
min-height: 3.5rem;
padding: 0 0.5rem 0 1rem;
border-radius: var(--border-radius-messages);
background-color: var(--color-background);
box-shadow: 0 1px 2px var(--color-default-shadow);
}
textarea {
resize: none;
flex: 1;
min-width: 0;
margin: 0;
padding: 1rem 0;
border: 0;
font: inherit;
font-size: 1rem;
line-height: 1.375;
color: var(--color-text);
background: transparent;
outline: none;
&::placeholder {
color: var(--color-placeholders);
}
}
.attach {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
margin-bottom: 0.5rem;
border: 0;
border-radius: 50%;
color: var(--color-composer-button);
background: transparent;
transition: color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.preview {
display: block;
max-width: 100%;
max-height: 16rem;
margin: 0 auto 1rem;
border-radius: var(--border-radius-default-small);
object-fit: contain;
}
.file {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.tile {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.caption {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
&:focus {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
}
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: calc(100% + 2.5rem);
margin: 0.75rem -1.25rem 0;
padding: 0.5rem 1.25rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.label {
display: flex;
flex: 1;
flex-direction: column;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
@media (pointer: coarse) {
.SavedComposer {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,53 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import { menuCloser } from "#lib/components/ui/ContextMenu.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { type SavedOutcome, savedUi } from "#lib/stores/saved.svelte.js";
interface Props {
chatId: number;
copy: boolean;
messageId: number;
}
const { chatId, copy, messageId }: Props = $props();
const DONE_DELAY = 900;
const FAILED_DELAY = 2400;
const close = menuCloser();
let busy = $state(false);
let result = $state<SavedOutcome | null>(null);
const icon = $derived.by(() => {
if (result === null) {
return "forward";
}
return result.ok ? "check" : "warning";
});
async function send() {
if (busy || result !== null) {
return;
}
busy = true;
result = await savedUi.forward(chatId, messageId);
busy = false;
setTimeout(close, result.ok ? DONE_DELAY : FAILED_DELAY);
}
</script>
<ContextMenu.Item class="bg-menu-item" closeOnSelect={false} onSelect={send}>
{#if busy}
<Spinner color="gray" size="1.125rem" />
{:else}
<Icon name={icon} size="1.125rem" />
{/if}
{#if result}
{t(result.label)}
{:else}
{copy ? t("saved.copy") : t("saved.forward")}
{/if}
</ContextMenu.Item>
+15 -16
View File
@@ -2,9 +2,11 @@
import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js"; import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
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 { 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 { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -14,10 +16,11 @@
let { chatId }: Props = $props(); let { chatId }: Props = $props();
let busy = $state(false); let busy = $state(false);
const started = createFlash<true>();
const item = $derived(discover.get(chatId)); const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false); const tracked = $derived(item?.tracked ?? false);
const title = $derived(item?.title ?? "Этот чат"); const title = $derived(item?.title ?? t("track.thisChat"));
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
@@ -33,10 +36,10 @@
busy = true; busy = true;
try { try {
discover.set(await trackChat(chatId, true)); discover.set(await trackChat(chatId, true));
toasts.success("Отслеживание включено, история загружается"); started.show(true);
chats.refresh(); chats.refresh();
} catch { } catch {
toasts.error("Не удалось включить отслеживание"); toasts.error(t("track.failed"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -49,9 +52,9 @@
busy = true; busy = true;
try { try {
await enqueueBackfill(chatId, true, true); await enqueueBackfill(chatId, true, true);
toasts.success("Полный бэкфилл запущен"); started.show(true);
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("jobs.backfillFailed"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -59,26 +62,22 @@
</script> </script>
<div class="track"> <div class="track">
<div class="title">Здесь пока нет сообщений</div> <div class="title">{t("track.empty")}</div>
<p class="description"> <p class="description">
{#if tracked} {tracked ? t("track.tracked", { title }) : t("track.hint")}
{title}
отслеживается — новые сообщения и статистика собираются автоматически.
{:else}
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
этому чату.
{/if}
</p> </p>
<div class="actions"> <div class="actions">
{#if tracked} {#if tracked}
<Button loading={busy} onclick={backfill} pill variant="secondary"> <Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" /> <Icon name={started.current ? "check" : "cloud-download"} />
<span>Загрузить историю</span> <span
>{started.current ? t("track.loading") : t("track.loadHistory")}</span
>
</Button> </Button>
{:else} {:else}
<Button loading={busy} onclick={track} pill variant="primary"> <Button loading={busy} onclick={track} pill variant="primary">
<Icon name="stats" /> <Icon name="stats" />
<span>Начать отслеживать</span> <span>{t("track.start")}</span>
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -16,12 +16,14 @@
import { formatFull, formatListDate } from "#lib/format/datetime.js"; import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { import {
alertAvatar, alertAvatar,
alertHref,
alertLabel, alertLabel,
alertPreview, alertPreview,
alertTitle, alertTitle,
EVENT_ICONS, EVENT_ICONS,
} from "#lib/format/watches.js"; } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js"; import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -48,15 +50,7 @@
); );
function writeParam(value: Filter | null) { function writeParam(value: Filter | null) {
const url = new URL(page.url.href); replaceParam(ALERTS_PARAM, value);
if (value === null) {
url.searchParams.delete(ALERTS_PARAM);
} else {
url.searchParams.set(ALERTS_PARAM, value);
}
if (url.href !== page.url.href) {
goto(url, { replace: true, reset: false });
}
} }
function setFilter(next: Filter) { function setFilter(next: Filter) {
@@ -135,10 +129,10 @@
async function openAlert(alert: AlertItem) { async function openAlert(alert: AlertItem) {
seen(alert); seen(alert);
await goto(alert.path);
if (isMobile()) { if (isMobile()) {
ui.closePanel(); ui.resetPanel();
} }
await goto(alertHref(alert));
} }
async function seeAll() { async function seeAll() {
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import Button from "../ui/Button.svelte"; import Button from "../ui/Button.svelte";
interface Props { interface Props {
@@ -13,7 +14,7 @@
const { const {
initial = "", initial = "",
saving = false, saving = false,
submitLabel = "Добавить заметку", submitLabel,
oncancel, oncancel,
onsubmit, onsubmit,
}: Props = $props(); }: Props = $props();
@@ -41,15 +42,19 @@
}} }}
> >
<textarea <textarea
placeholder="Заметка к сообщению…" placeholder={t("notes.placeholder")}
rows={oncancel ? 2 : 3} rows={oncancel ? 2 : 3}
bind:value={text} bind:value={text}
></textarea> ></textarea>
<div class="actions"> <div class="actions">
{#if oncancel} {#if oncancel}
<Button onclick={oncancel} variant="translucent">Отмена</Button> <Button onclick={oncancel} variant="translucent">
{t("common.cancel")}
</Button>
{/if} {/if}
<Button disabled={saving || !valid} type="submit">{submitLabel}</Button> <Button disabled={saving || !valid} type="submit">
{submitLabel ?? t("notes.add")}
</Button>
</div> </div>
</form> </form>
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Annotation } from "#lib/api/types.js"; import type { Annotation } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import Icon from "../ui/Icon.svelte"; import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte"; import AnnotationEditor from "./AnnotationEditor.svelte";
import NoteText from "./NoteText.svelte";
interface Props { interface Props {
annotations: Annotation[]; annotations: Annotation[];
@@ -28,14 +30,14 @@
onupdate(annotation, text); onupdate(annotation, text);
editingId = null; editingId = null;
}} }}
submitLabel="Сохранить" submitLabel={t("common.save")}
/> />
{:else} {:else}
<p class="text">{annotation.text}</p> <NoteText text={annotation.text} />
<div class="meta"> <div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span> <span class="time">{formatFull(annotation.updated_at)}</span>
<button <button
aria-label="Изменить" aria-label={t("notes.edit")}
class="action" class="action"
onclick={() => { onclick={() => {
editingId = annotation.id; editingId = annotation.id;
@@ -45,7 +47,7 @@
<Icon name="edit" size="1rem" /> <Icon name="edit" size="1rem" />
</button> </button>
<button <button
aria-label="Удалить" aria-label={t("common.delete")}
class="action" class="action"
onclick={() => ondelete(annotation)} onclick={() => ondelete(annotation)}
type="button" type="button"
@@ -74,12 +76,6 @@
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.meta { .meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -9,7 +9,9 @@
import type { Annotation } from "#lib/api/types.js"; import type { Annotation } from "#lib/api/types.js";
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 { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
import AnnotationEditor from "./AnnotationEditor.svelte"; import AnnotationEditor from "./AnnotationEditor.svelte";
@@ -40,6 +42,7 @@
.then((rows) => { .then((rows) => {
if (current === token) { if (current === token) {
items = rows; items = rows;
notes.mark(cid, mid, rows.length > 0);
} }
}) })
.finally(() => { .finally(() => {
@@ -58,6 +61,7 @@
try { try {
const created = await createAnnotation(chatId, messageId, text); const created = await createAnnotation(chatId, messageId, text);
items = [created, ...items]; items = [created, ...items];
notes.mark(chatId, messageId, true);
} finally { } finally {
saving = false; saving = false;
} }
@@ -71,11 +75,12 @@
async function remove(annotation: Annotation) { async function remove(annotation: Annotation) {
await deleteAnnotation(annotation.id); await deleteAnnotation(annotation.id);
items = items.filter((item) => item.id !== annotation.id); items = items.filter((item) => item.id !== annotation.id);
notes.mark(annotation.chat_id, annotation.message_id, items.length > 0);
} }
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Заметки" /> <EmptyState description={t("ui.noMessage")} title={t("notes.title")} />
{:else} {:else}
<div class="panel-content"> <div class="panel-content">
<AnnotationEditor onsubmit={add} {saving} /> <AnnotationEditor onsubmit={add} {saving} />
@@ -0,0 +1,84 @@
<script lang="ts">
import { noteParts, notes } from "#lib/stores/notes.svelte.js";
interface Props {
interactive?: boolean;
text: string;
}
const { interactive = true, text }: Props = $props();
const parts = $derived(noteParts(text));
function openTag(event: MouseEvent, tag: string) {
event.stopPropagation();
notes.openTag(tag);
}
</script>
<p class="note-text">
{#each parts as part, index (index)}
{#if "tag" in part && interactive}
<button
class="tag"
onclick={(event) => openTag(event, part.tag)}
type="button"
class:active={notes.tag === part.tag}
>
{part.label}
</button>
{:else if "tag" in part}
<span class="tag" class:active={notes.tag === part.tag}
>{part.label}</span
>
{:else}
{part.text}
{/if}
{/each}
</p>
<style lang="scss">
.note-text {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
display: inline;
padding: 0 0.3125rem;
border: 0;
border-radius: 0.375rem;
font: inherit;
font-size: 0.875em;
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
transition: background-color 0.15s ease;
&.active {
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (hover: hover) {
&:is(button):hover {
background-color: color-mix(
in srgb,
var(--color-primary) 20%,
transparent
);
}
}
&:is(button) {
cursor: pointer;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -0,0 +1,402 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { ripple } from "#lib/actions/ripple.js";
import {
listNotes,
listNoteTags,
type Note,
type NoteTag,
} from "#lib/api/notes.js";
import NoteText from "#lib/components/annotations/NoteText.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { NOTES_PARAM, notes } from "#lib/stores/notes.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 50;
let items = $state<Note[]>([]);
let tags = $state<NoteTag[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(false);
let failed = $state(false);
let token = 0;
const chips = $derived.by(() => {
const current = notes.tag;
if (current === null || tags.some((item) => item.tag === current)) {
return tags;
}
return [{ count: 0, tag: current }, ...tags];
});
async function load(tag: string | null, offset = 0) {
token += 1;
const mine = token;
if (offset === 0) {
loading = true;
} else {
loadingMore = true;
}
try {
const page = await listNotes(tag, offset, PAGE);
if (mine !== token) {
return;
}
items = offset === 0 ? page : [...items, ...page];
hasMore = page.length === PAGE;
failed = false;
} catch {
if (mine === token) {
failed = true;
}
} finally {
if (mine === token) {
loading = false;
loadingMore = false;
}
}
}
$effect(() => {
const { tag } = notes;
untrack(() => {
replaceParam(NOTES_PARAM, tag ?? "");
load(tag);
});
});
onMount(() => {
listNoteTags()
.then((result) => {
tags = result;
})
.catch(() => undefined);
return () => {
notes.tag = null;
replaceParam(NOTES_PARAM, null);
};
});
function pick(tag: string | null) {
notes.tag = notes.tag === tag ? null : tag;
}
function href(note: Note): string {
return `/app/${note.chat_id}?message=${note.message_id}`;
}
function open(event: MouseEvent, note: Note) {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.button !== 0
) {
return;
}
event.preventDefault();
ui.requestJump(note.chat_id, note.message_id);
if (isMobile()) {
ui.resetPanel();
goto(href(note));
return;
}
const tag = notes.tag === null ? "" : encodeURIComponent(notes.tag);
goto(`${href(note)}&${NOTES_PARAM}=${tag}`);
}
function chatTitle(note: Note): string {
return note.chat_title ?? t("chat.fallbackTitle", { id: note.chat_id });
}
function quote(note: Note): string {
if (note.message_date === null) {
return t("notes.messageGone");
}
return note.message_text ?? t("notes.mediaMessage");
}
</script>
<div class="notes">
{#if chips.length > 0}
<div class="chips">
<button
aria-pressed={notes.tag === null}
class="chip"
onclick={() => pick(null)}
type="button"
class:on={notes.tag === null}
>
{t("notes.all")}
</button>
{#each chips as item (item.tag)}
<button
aria-pressed={notes.tag === item.tag}
class="chip"
onclick={() => pick(item.tag)}
type="button"
class:on={notes.tag === item.tag}
>
#{item.tag}
{#if item.count > 0}
<span class="value">{item.count}</span>
{/if}
</button>
{/each}
</div>
{/if}
{#if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if failed && items.length === 0}
<div class="state">
<p>{t("notes.failed")}</p>
<Button onclick={() => load(notes.tag)} variant="text">
{t("notes.retry")}
</Button>
</div>
{:else if items.length === 0}
<EmptyState
description={notes.tag === null ? t("notes.empty") : undefined}
title={notes.tag === null
? t("notes.emptyTitle")
: t("notes.emptyTag", { tag: notes.tag })}
/>
{:else}
<ul class="list">
{#each items as note (note.id)}
<li>
<a
class="row"
href={href(note)}
onclick={(event) => open(event, note)}
use:ripple
>
<Avatar
avatar={{
id: note.chat_id,
kind: note.chat_id > 0 ? "peer" : "chat",
}}
colorKey={note.chat_id}
hasAvatar={note.chat_has_avatar}
name={chatTitle(note)}
size={2.75}
/>
<div class="body">
<div class="top">
<span class="title">{chatTitle(note)}</span>
<span class="date">{formatListDate(note.created_at)}</span>
</div>
<div class="text">
<NoteText interactive={false} text={note.text} />
</div>
<div class="quote" class:deleted={note.message_deleted}>
{quote(note)}
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if hasMore}
<div class="more">
<Button
loading={loadingMore}
onclick={() => load(notes.tag, items.length)}
variant="text"
>
{t("notes.more")}
</Button>
</div>
{/if}
{/if}
</div>
<style lang="scss">
.notes {
display: flex;
flex-direction: column;
min-height: 100%;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-borders);
}
.chip {
cursor: pointer;
display: inline-flex;
gap: 0.375rem;
align-items: center;
height: 1.75rem;
padding: 0 0.5625rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text);
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&.on {
background-color: color-mix(
in srgb,
var(--color-primary) 12%,
transparent
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem;
border-radius: 1.125rem;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.value {
color: var(--color-text-secondary);
}
.list {
margin: 0;
padding: 0.5rem;
list-style: none;
}
.row {
position: relative;
overflow: hidden;
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.5rem 0.625rem;
border-radius: 0.625rem;
color: var(--color-text);
text-decoration: none;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.body {
overflow: hidden;
flex: 1;
min-width: 0;
}
.top {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.title {
overflow: hidden;
flex: 1;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.text {
margin-top: 0.125rem;
font-size: 0.9375rem;
line-height: 1.35;
}
.quote {
overflow: hidden;
margin-top: 0.375rem;
padding-inline-start: 0.5rem;
border-inline-start: 2px solid var(--color-primary);
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.deleted {
text-decoration: line-through;
}
}
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.state {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 2rem 1rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
display: flex;
justify-content: center;
padding: 0 0.5rem 1rem;
}
</style>
@@ -8,6 +8,8 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.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 type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.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";
@@ -17,14 +19,19 @@
let token = $state(""); let token = $state("");
let adding = $state(false); let adding = $state(false);
const statusLabels: Record<string, string> = { const STATUS_KEYS: Record<string, MessageKey> = {
error: "ошибка", error: "bots.status.error",
ok: "слушает", ok: "bots.status.ok",
pending: "подключается", pending: "bots.status.pending",
removed: "отключён", removed: "bots.status.removed",
unauthorized: "токен отклонён", unauthorized: "bots.status.unauthorized",
}; };
function statusLabel(status: string): string {
const key = STATUS_KEYS[status];
return key ? t(key) : status;
}
async function load() { async function load() {
loading = true; loading = true;
try { try {
@@ -49,7 +56,7 @@
await load(); await load();
await accounts.load(); await accounts.load();
} catch { } catch {
toasts.error("Не похоже на токен бота"); toasts.error(t("bots.badToken"));
} finally { } finally {
adding = false; adding = false;
} }
@@ -81,7 +88,7 @@
{#if loading} {#if loading}
<Spinner /> <Spinner />
{:else if bots.length === 0} {:else if bots.length === 0}
<p class="empty">Ботов пока нет.</p> <p class="empty">{t("bots.empty")}</p>
{:else} {:else}
<ul class="bots"> <ul class="bots">
{#each bots as bot (bot.bot_id)} {#each bots as bot (bot.bot_id)}
@@ -94,9 +101,9 @@
> >
<span class="name">{botName(bot)}</span> <span class="name">{botName(bot)}</span>
<span class="meta"> <span class="meta">
{statusLabels[bot.status] ?? bot.status} {statusLabel(bot.status)}
{#if bot.account_id !== null} {#if bot.account_id !== null}
· аккаунт {bot.account_id} · {t("bots.account", { id: bot.account_id })}
{/if} {/if}
</span> </span>
{#if bot.status_detail} {#if bot.status_detail}
@@ -105,7 +112,7 @@
</button> </button>
{#if bot.enabled} {#if bot.enabled}
<Button <Button
aria-label="Отключить" aria-label={t("bots.disconnect")}
onclick={() => remove(bot)} onclick={() => remove(bot)}
round round
smaller smaller
@@ -121,25 +128,14 @@
<div class="add"> <div class="add">
<label class="field"> <label class="field">
<span>Токен бота от @BotFather</span> <span>{t("bots.token")}</span>
<input placeholder="123456:AA…" type="password" bind:value={token}> <input placeholder="123456:AA…" type="password" bind:value={token}>
</label> </label>
<Button fluid loading={adding} onclick={add}>Добавить</Button> <Button fluid loading={adding} onclick={add}>{t("bots.add")}</Button>
</div> </div>
<p class="note"> <p class="note">{t("bots.login")}</p>
Бот входит по MTProto отдельной авторизацией — это не отнимает getUpdates у <p class="note">{t("bots.history")}</p>
того, кто держит того же бота через Bot API, и не требует вебхука. Дальше он
становится обычным аккаунтом архива: чаты, сообщения, медиа и участники
групп видны там же, где переписка остальных аккаунтов.
</p>
<p class="note">
Прошлое подтягивается из очереди недоставленных апдейтов, которую Telegram
держит для бота; история диалогов боту запрещена, глубже этой очереди
заглянуть нельзя. В личке бот видит каждого, кто ему написал; в группах всё
— только при выключенном privacy mode. Отключение убирает токен и сессию,
архив остаётся.
</p>
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -13,8 +13,9 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.js"; import { filesLabel } from "#lib/format/storage.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
interface Props { interface Props {
label?: string; label?: string;
@@ -28,12 +29,12 @@
let confirming = $state(false); let confirming = $state(false);
let moving = $state(false); let moving = $state(false);
let moved = $state<TrashOutcome | null>(null); let moved = $state<TrashOutcome | null>(null);
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
function where(scope: StorageScope): string { function where(scope: StorageScope): string {
return "folderId" in target return "folderId" in target
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки` ? t("storage.inFolder", { count: scope.chats })
: "в этом чате"; : t("storage.inChat");
} }
async function load() { async function load() {
@@ -56,7 +57,7 @@
await load(); await load();
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось переместить файлы в корзину"; problem = "storage.scope.trashFailed";
} finally { } finally {
moving = false; moving = false;
} }
@@ -74,7 +75,7 @@
await load(); await load();
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} finally { } finally {
moving = false; moving = false;
} }
@@ -90,15 +91,15 @@
{#if moved && moved.files > 0} {#if moved && moved.files > 0}
<div class="files"> <div class="files">
<div class="text"> <div class="text">
<span>{filesLabel(moved.files)} в корзине</span> <span>{t("storage.inTrash", { files: filesLabel(moved.files) })}</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small> <small>{t("storage.freed", { size: formatBytes(moved.bytes) })}</small>
{#if problem} {#if problem}
<small class="failed">{problem}</small> <small class="failed">{t(problem)}</small>
{/if} {/if}
</div> </div>
<Button disabled={moving} onclick={undo} smaller variant="text"> <Button disabled={moving} onclick={undo} smaller variant="text">
<Icon name="undo" size="1.25rem" /> <Icon name="undo" size="1.25rem" />
<span>Вернуть</span> <span>{t("storage.restore")}</span>
</Button> </Button>
</div> </div>
{:else if files && files.files > 0} {:else if files && files.files > 0}
@@ -106,22 +107,19 @@
{#if confirming} {#if confirming}
<div class="text"> <div class="text">
<span> <span>
{filesLabel(files.files)}, {formatBytes(files.bytes)}, {t("storage.scope.confirm", {
{plural( count: files.files,
files.files, files: filesLabel(files.files),
"уйдёт в корзину и перестанет скачиваться сам", size: formatBytes(files.bytes),
"уйдут в корзину и перестанут скачиваться сами", })}
"уйдут в корзину и перестанут скачиваться сами"
)}.
</span> </span>
{#if files.gone > 0} {#if files.gone > 0}
<small class="warn"> <small class="warn">
{files.gone} {t("storage.scope.gone", { count: files.gone })}
из них потом не скачать заново
</small> </small>
{/if} {/if}
{#if problem} {#if problem}
<small class="failed">{problem}</small> <small class="failed">{t(problem)}</small>
{/if} {/if}
<div class="actions"> <div class="actions">
<Button <Button
@@ -131,7 +129,7 @@
smaller smaller
variant="text" variant="text"
> >
Отмена {t("common.cancel")}
</Button> </Button>
<Button <Button
disabled={moving} disabled={moving}
@@ -141,7 +139,7 @@
smaller smaller
> >
<Icon name="delete" size="1.25rem" /> <Icon name="delete" size="1.25rem" />
<span>В корзину</span> <span>{t("storage.toTrash")}</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -156,18 +154,18 @@
</small> </small>
{/if} {/if}
{:else} {:else}
<span>{formatBytes(files.bytes)} уже скачано</span> <span>
{t("storage.scope.downloaded", { size: formatBytes(files.bytes) })}
</span>
<small>{filesLabel(files.files)} {where(files)}</small> <small>{filesLabel(files.files)} {where(files)}</small>
{/if} {/if}
{#if files.kept > 0} {#if files.kept > 0}
<small> <small>
Ещё {filesLabel(files.kept)} есть и в других чатах, {t("storage.scope.kept", {
{plural( count: files.kept,
files.kept, files: filesLabel(files.kept),
"он останется", n: files.kept.toLocaleString(locale.tag),
"они останутся", })}
"они останутся"
)}
</small> </small>
{/if} {/if}
</div> </div>
@@ -180,7 +178,7 @@
variant="gray" variant="gray"
> >
<Icon name="delete" size="1.25rem" /> <Icon name="delete" size="1.25rem" />
<span>В корзину</span> <span>{t("storage.toTrash")}</span>
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -189,10 +187,10 @@
<div class="text"> <div class="text">
<span>{label}</span> <span>{label}</span>
<small> <small>
{filesLabel(files.kept)} {t("storage.scope.keptOnly", {
есть и в других чатах, убрать count: files.kept,
{plural(files.kept, "его", "их", "их")} files: filesLabel(files.kept),
можно из списка ниже })}
</small> </small>
</div> </div>
</div> </div>
@@ -4,7 +4,7 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { filesLabel, GROUP_LABELS } from "#lib/format/storage.js"; import { filesLabel, groupLabel } from "#lib/format/storage.js";
interface Props { interface Props {
chat: StorageChat; chat: StorageChat;
@@ -24,7 +24,7 @@
const parts = $derived(chat.groups.filter((item) => item.bytes > 0)); const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
const makeup = $derived( const makeup = $derived(
parts parts
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`) .map((item) => `${groupLabel(item.group)}: ${formatBytes(item.bytes)}`)
.join(", ") .join(", ")
); );
</script> </script>
@@ -31,9 +31,11 @@
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { import {
filesLabel, filesLabel,
GROUP_LABELS, groupLabel,
messagesLabel, messagesLabel,
} from "#lib/format/storage.js"; } from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { import {
@@ -53,9 +55,9 @@
const PAGE = 30; const PAGE = 30;
const UNDO_MS = 12_000; const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600; const UNDO_GUARD_MS = 600;
const LISTINGS: { label: string; value: StorageListing }[] = [ const LISTINGS: { label: MessageKey; value: StorageListing }[] = [
{ label: "Файлы", value: "files" }, { label: "storage.listing.files", value: "files" },
{ label: "Чаты", value: "chats" }, { label: "storage.listing.chats", value: "chats" },
]; ];
let summary = $state<StorageSummary | null>(null); let summary = $state<StorageSummary | null>(null);
@@ -70,7 +72,7 @@
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>( let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null null
); );
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<number>(); const selected = new SvelteSet<number>();
@@ -89,12 +91,11 @@
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0); const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
const emptiness = $derived.by(() => { const emptiness = $derived.by(() => {
if (group !== null) { if (group !== null) {
return "В этой группе нет файлов."; return t("storage.empty.group");
} }
return chatId === null return chatId === null ? t("storage.empty.all") : t("storage.empty.chat");
? "На диске нет скачанных файлов."
: "В этом чате нет скачанных файлов.";
}); });
const failure = $derived(problem && t(problem));
const chosen = $derived(files.filter((file) => selected.has(file.media_id))); const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived( const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0) chosen.reduce((sum, file) => sum + file.file_size, 0)
@@ -120,7 +121,7 @@
); );
function chatTitle(id: number): string { function chatTitle(id: number): string {
return chats.byId(id)?.title ?? `Чат ${id}`; return chats.byId(id)?.title ?? t("ui.chat", { id });
} }
async function loadSummary() { async function loadSummary() {
@@ -249,11 +250,11 @@
scope?.refresh(); scope?.refresh();
showUndo(outcome, trashed); showUndo(outcome, trashed);
if (outcome.blocked.length > 0) { if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске"; problem = "storage.blocked";
} }
await loadSummary(); await loadSummary();
} catch { } catch {
problem = "Не удалось переместить в корзину"; problem = "storage.trashFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -273,7 +274,7 @@
scope?.refresh(); scope?.refresh();
await loadSummary(); await loadSummary();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} }
} }
@@ -281,7 +282,7 @@
try { try {
await downloadMedia(file.media_id); await downloadMedia(file.media_id);
} catch { } catch {
problem = "Не удалось скачать файл"; problem = "ui.downloadFailed";
} }
} }
@@ -330,22 +331,26 @@
{#if summary.files > 0 || chatId !== null} {#if summary.files > 0 || chatId !== null}
<section class="summary"> <section class="summary">
<div class="hero"> <div class="hero">
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span> <span class="total">
{formatBytes(summary.bytes) || t("storage.zero")}
</span>
<span class="count"> <span class="count">
{filesLabel(summary.files)}
{#if chatId !== null} {#if chatId !== null}
в этом чате {t("storage.hero.chat", { files: filesLabel(summary.files) })}
{:else if summary.trash_bytes > 0} {:else if summary.trash_bytes > 0}
· ещё {formatBytes(summary.trash_bytes)} в корзине {t("storage.hero.trash", {
files: filesLabel(summary.files),
size: formatBytes(summary.trash_bytes),
})}
{:else} {:else}
на диске {t("storage.hero.disk", { files: filesLabel(summary.files) })}
{/if} {/if}
</span> </span>
</div> </div>
<div class="stack" class:bare={groups.length === 0}> <div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)} {#each groups as item (item.group)}
<span <span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}" title="{groupLabel(item.group)}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})" style:background-color="var(--color-storage-{item.group})"
style:flex-grow={item.bytes} style:flex-grow={item.bytes}
></span> ></span>
@@ -353,7 +358,9 @@
{#if chatId === null && summary.trash_bytes > 0} {#if chatId === null && summary.trash_bytes > 0}
<span <span
class="trashed" class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}" title={t("storage.trash.title", {
size: formatBytes(summary.trash_bytes),
})}
style:flex-grow={summary.trash_bytes} style:flex-grow={summary.trash_bytes}
></span> ></span>
{/if} {/if}
@@ -371,7 +378,7 @@
class="dot" class="dot"
style:background-color="var(--color-storage-{item.group})" style:background-color="var(--color-storage-{item.group})"
></span> ></span>
{GROUP_LABELS[item.group]} {groupLabel(item.group)}
<span class="value">{formatBytes(item.bytes)}</span> <span class="value">{formatBytes(item.bytes)}</span>
</button> </button>
{/each} {/each}
@@ -386,14 +393,14 @@
type="button" type="button"
> >
<Icon name="delete" size="1.375rem" /> <Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span> <span class="label">{t("storage.trash")}</span>
<span class="value"> <span class="value">
{#if summary.trash_files > 0} {#if summary.trash_files > 0}
<span class="swatch"></span> <span class="swatch"></span>
{filesLabel(summary.trash_files)} {filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)} · {formatBytes(summary.trash_bytes)}
{:else} {:else}
пусто {t("storage.trash.nothing")}
{/if} {/if}
</span> </span>
<Icon name="next" size="1.125rem" /> <Icon name="next" size="1.125rem" />
@@ -407,13 +414,13 @@
type="button" type="button"
class:on={listing === item.value} class:on={listing === item.value}
> >
{item.label} {t(item.label)}
</button> </button>
{/each} {/each}
</div> </div>
{:else} {:else}
<ScopeFiles <ScopeFiles
label="Все файлы чата" label={t("storage.chatFiles")}
onchange={() => { onchange={() => {
hideUndo(); hideUndo();
reload(); reload();
@@ -426,12 +433,14 @@
{#if listing === "files" && files.length > 0} {#if listing === "files" && files.length > 0}
<div class="section-title"> <div class="section-title">
<h3>Самые тяжёлые</h3> <h3>{t("storage.heaviest")}</h3>
{#if summary && group === null && summary.heavy_files > 0} {#if summary && group === null && summary.heavy_files > 0}
<span> <span>
{filesLabel(summary.heavy_files)} {t("storage.heavy", {
крупнее {formatBytes(summary.heavy_threshold)} files: filesLabel(summary.heavy_files),
= {formatBytes(summary.heavy_bytes)} size: formatBytes(summary.heavy_bytes),
threshold: formatBytes(summary.heavy_threshold),
})}
</span> </span>
{/if} {/if}
</div> </div>
@@ -448,17 +457,17 @@
> >
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}> <ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
Файлы чата {t("storage.menu.chatFiles")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}> <ContextMenuItem icon="reply" onselect={() => visit(chat)}>
Перейти в чат {t("storage.menu.openChat")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => onselect={() =>
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))} policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
> >
Политика захвата {t("storage.menu.policy")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</StorageChatRow> </StorageChatRow>
@@ -484,19 +493,19 @@
> >
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}> <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть {t("ui.open")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}> <ContextMenuItem icon="reply" onselect={() => jump(file)}>
Перейти к сообщению {t("ui.jumpToMessage")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}> <ContextMenuItem icon="download" onselect={() => save(file)}>
Скачать файл {t("ui.downloadFile")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
icon="delete" icon="delete"
onselect={() => trash([file.media_id])} onselect={() => trash([file.media_id])}
> >
В корзину {t("storage.toTrash")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</StorageRow> </StorageRow>
@@ -506,29 +515,31 @@
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить хранилище.</p> <p>{t("storage.loadFailed")}</p>
<Button onclick={() => reload()} smaller variant="text"> <Button onclick={() => reload()} smaller variant="text">
Повторить {t("ui.retry")}
</Button> </Button>
</div> </div>
{:else if shown === 0} {:else if shown === 0}
<div class="state"><p>{emptiness}</p></div> <div class="state"><p>{emptiness}</p></div>
{:else if !done} {:else if !done}
<button class="more" onclick={() => loadMore()} type="button"> <button class="more" onclick={() => loadMore()} type="button">
Показать ещё {t("ui.showMore")}
</button> </button>
{/if} {/if}
</div> </div>
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
failure={problem} {failure}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}" title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn warn
> >
<Button onclick={() => selected.clear()} smaller variant="text"> <Button onclick={() => selected.clear()} smaller variant="text">
Снять {t("storage.deselect")}
</Button> </Button>
<Button <Button
disabled={busy} disabled={busy}
@@ -538,22 +549,22 @@
smaller smaller
> >
<Icon name="delete" size="1.25rem" /> <Icon name="delete" size="1.25rem" />
<span>В корзину</span> <span>{t("storage.toTrash")}</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if undo} {:else if undo}
<SelectionBar <SelectionBar
failure={problem} {failure}
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней" note={t("storage.freed", { size: formatBytes(undo.outcome.bytes) })}
title="{filesLabel(undo.outcome.files)} в корзине" title={t("storage.inTrash", { files: filesLabel(undo.outcome.files) })}
> >
<Button onclick={restoreLast} smaller variant="text"> <Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" /> <Icon name="undo" size="1.25rem" />
<span>Вернуть</span> <span>{t("storage.restore")}</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem} {:else if failure}
<SelectionBar failure={problem} title="Не получилось"> <SelectionBar {failure} title={t("storage.failed")}>
<Button <Button
onclick={() => { onclick={() => {
problem = null; problem = null;
@@ -561,7 +572,7 @@
smaller smaller
variant="text" variant="text"
> >
Понятно {t("ui.gotIt")}
</Button> </Button>
</SelectionBar> </SelectionBar>
{/if} {/if}
@@ -5,6 +5,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { groupOf } from "#lib/format/storage.js"; import { groupOf } from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
fileName: string; fileName: string;
@@ -54,7 +55,7 @@
> >
<span class="check"> <span class="check">
<input <input
aria-label="Выбрать {fileName}" aria-label={t("storage.select", { name: fileName })}
checked={selected} checked={selected}
onchange={ontoggle} onchange={ontoggle}
type="checkbox" type="checkbox"
@@ -82,7 +83,7 @@
<span class="note">{note}</span> <span class="note">{note}</span>
{/if} {/if}
{#if !refetchable} {#if !refetchable}
<span class="gone">Не скачать заново</span> <span class="gone">{t("storage.gone")}</span>
{/if} {/if}
</span> </span>
<span class="size">{formatBytes(size)}</span> <span class="size">{formatBytes(size)}</span>
@@ -19,12 +19,13 @@
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 { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import { import {
filesLabel, filesLabel,
messagesLabel, messagesLabel,
purgeLabel, purgeLabel,
} from "#lib/format/storage.js"; } from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js"; import { isMobile } from "#lib/viewport.js";
@@ -40,7 +41,7 @@
let failed = $state(false); let failed = $state(false);
let busy = $state(false); let busy = $state(false);
let doomed = $state<TrashItem[] | null>(null); let doomed = $state<TrashItem[] | null>(null);
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<string>(); const selected = new SvelteSet<string>();
@@ -58,6 +59,7 @@
const doomedGone = $derived( const doomedGone = $derived(
doomed?.filter((item) => !item.refetchable).length ?? 0 doomed?.filter((item) => !item.refetchable).length ?? 0
); );
const failure = $derived(problem && t(problem));
const viewerItems = $derived<ViewerItem[]>( const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({ items.map((item) => ({
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`, caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
@@ -77,7 +79,7 @@
} }
function chatTitle(chatId: number): string { function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`; return chats.byId(chatId)?.title ?? t("ui.chat", { id: chatId });
} }
async function loadTotal() { async function loadTotal() {
@@ -132,7 +134,7 @@
await restoreFromTrash(keys); await restoreFromTrash(keys);
drop(keys); drop(keys);
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -148,7 +150,7 @@
await restoreFromTrash(null); await restoreFromTrash(null);
await load(); await load();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -167,7 +169,7 @@
everything = false; everything = false;
await load(); await load();
} catch { } catch {
problem = "Не удалось удалить файлы"; problem = "storage.trash.deleteFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -190,15 +192,12 @@
</script> </script>
<div class="trash"> <div class="trash">
<p class="note"> <p class="note">{t("storage.trash.note")}</p>
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
вернуть.
</p>
{#if total.files > 0} {#if total.files > 0}
<button class="act" disabled={busy} onclick={restoreAll} type="button"> <button class="act" disabled={busy} onclick={restoreAll} type="button">
<Icon name="undo" size="1.375rem" /> <Icon name="undo" size="1.375rem" />
<span class="label">Вернуть всё</span> <span class="label">{t("storage.trash.restoreAll")}</span>
<span class="value">{filesLabel(total.files)}</span> <span class="value">{filesLabel(total.files)}</span>
</button> </button>
<button <button
@@ -209,7 +208,7 @@
type="button" type="button"
> >
<Icon name="delete" size="1.375rem" /> <Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span> <span class="label">{t("storage.trash.clear")}</span>
<span class="value">{formatBytes(total.bytes)}</span> <span class="value">{formatBytes(total.bytes)}</span>
</button> </button>
{/if} {/if}
@@ -233,13 +232,13 @@
> >
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}> <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть {t("ui.open")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="undo" onselect={() => restore([item])}> <ContextMenuItem icon="undo" onselect={() => restore([item])}>
Вернуть {t("storage.restore")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(item)}> <ContextMenuItem icon="reply" onselect={() => jump(item)}>
Перейти к сообщению {t("ui.jumpToMessage")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
icon="delete" icon="delete"
@@ -247,7 +246,7 @@
doomed = [item]; doomed = [item];
}} }}
> >
Удалить насовсем {t("storage.trash.deleteForever")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</StorageRow> </StorageRow>
@@ -257,22 +256,24 @@
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить корзину.</p> <p>{t("storage.trash.loadFailed")}</p>
<Button onclick={load} smaller variant="text">Повторить</Button> <Button onclick={load} smaller variant="text">{t("ui.retry")}</Button>
</div> </div>
{:else if items.length === 0} {:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div> <div class="state"><p>{t("storage.trash.empty")}</p></div>
{:else if !done} {:else if !done}
<button class="more" onclick={loadMore} type="button"> <button class="more" onclick={loadMore} type="button">
Показать ещё {t("ui.showMore")}
</button> </button>
{/if} {/if}
</div> </div>
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
failure={dialogOpen ? null : problem} failure={dialogOpen ? null : failure}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}" title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn warn
> >
@@ -284,7 +285,7 @@
smaller smaller
variant="danger" variant="danger"
> >
Удалить {t("common.delete")}
</Button> </Button>
<Button <Button
disabled={busy} disabled={busy}
@@ -294,11 +295,11 @@
smaller smaller
> >
<Icon name="undo" size="1.25rem" /> <Icon name="undo" size="1.25rem" />
<span>Вернуть</span> <span>{t("storage.restore")}</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem && !dialogOpen} {:else if failure && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось"> <SelectionBar {failure} title={t("storage.failed")}>
<Button <Button
onclick={() => { onclick={() => {
problem = null; problem = null;
@@ -306,7 +307,7 @@
smaller smaller
variant="text" variant="text"
> >
Понятно {t("ui.gotIt")}
</Button> </Button>
</SelectionBar> </SelectionBar>
{/if} {/if}
@@ -327,59 +328,53 @@
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title"> <Dialog.Title class="dialog-title">
{everything ? "Очистить корзину?" : "Удалить насовсем?"} {everything
? t("storage.trash.clearTitle")
: t("storage.trash.deleteTitle")}
</Dialog.Title> </Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
<div class="dialog-body"> <div class="dialog-body">
{#if everything} {#if everything}
<p class="confirm"> <p class="confirm">
{filesLabel(total.files)}, {formatBytes(total.bytes)}, {t("storage.trash.willDelete", {
{plural( count: total.files,
total.files, files: filesLabel(total.files),
"будет удалён", size: formatBytes(total.bytes),
"будут удалены", })}
"будут удалены"
)}
с диска.
</p>
<p class="confirm rest">
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
удалённых там сообщений пропадут насовсем.
</p> </p>
<p class="confirm rest">{t("storage.trash.clearRest")}</p>
{:else if doomed} {:else if doomed}
<p class="confirm"> <p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))}, {t("storage.trash.willDelete", {
{plural( count: doomed.length,
doomed.length, files: filesLabel(doomed.length),
"будет удалён", size: formatBytes(bytesOf(doomed)),
"будут удалены", })}
"будут удалены"
)}
с диска.
</p> </p>
{#if doomedGone > 0} {#if doomedGone > 0}
<p class="confirm warn"> <p class="confirm warn">
<Icon name="warning" size="1.25rem" /> <Icon name="warning" size="1.25rem" />
<span> <span>
{filesLabel(doomedGone)} {t("storage.trash.goneWarn", {
скачать заново нельзя: в Telegram count: doomedGone,
{doomedGone === 1 ? "его" : "их"} files: filesLabel(doomedGone),
уже нет. })}
</span> </span>
</p> </p>
{/if} {/if}
{#if doomedGone < doomed.length} {#if doomedGone < doomed.length}
<p class="confirm rest"> <p class="confirm rest">
{doomedGone === 0 ? "Потом их" : "Остальные"} {doomedGone === 0
можно скачать из Telegram заново. ? t("storage.trash.laterAll", { count: doomed.length })
: t("storage.trash.laterRest")}
</p> </p>
{/if} {/if}
{/if} {/if}
{#if problem} {#if failure}
<p class="confirm failed">{problem}.</p> <p class="confirm failed">{failure}.</p>
{/if} {/if}
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
@@ -387,7 +382,9 @@
{#snippet child({ {#snippet child({
props, props,
})} })}
<Button {...props} pill variant="text">Отмена</Button> <Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet} {/snippet}
</Dialog.Close> </Dialog.Close>
<Button <Button
@@ -397,7 +394,7 @@
pill pill
variant="danger" variant="danger"
> >
Удалить {t("common.delete")}
</Button> </Button>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -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}
<FeedFilters {kind} onkind={pick} /> <div class="filters">
<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>
@@ -1,24 +1,42 @@
<script lang="ts"> <script lang="ts">
import type { FeedChatKind, FeedKind } from "#lib/api/types.js"; import type { FeedChatKind, FeedKind } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js"; import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js";
interface Props { interface Props {
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folder?: number | null;
kind: FeedKind; kind: FeedKind;
onchats?: (chats: FeedChatKind | null) => void; onchats?: (chats: FeedChatKind | null) => void;
onfolder?: (folder: number | null) => void;
onkind: (kind: FeedKind) => void; onkind: (kind: FeedKind) => void;
} }
let { kind, chats = null, onkind, onchats }: Props = $props(); let {
kind,
chats = null,
folder = null,
onkind,
onchats,
onfolder,
}: Props = $props();
const folderTitle = $derived(
folders.list.find((item) => item.folder_id === folder)?.title ??
t("feed.folder")
);
const KIND_KEYS = { const KIND_KEYS = {
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 = {
bot: "feed.chats.bot",
channel: "feed.chats.channel", channel: "feed.chats.channel",
dm: "feed.chats.dm", dm: "feed.chats.dm",
group: "feed.chats.group", group: "feed.chats.group",
@@ -54,6 +72,20 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if folder !== null && onfolder}
<div class="group">
<button
aria-pressed="true"
class="chip on"
onclick={() => onfolder(null)}
type="button"
>
<Icon name="folder" size="0.875rem" />
{folderTitle}
<Icon name="close" size="0.875rem" />
</button>
</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -69,7 +101,7 @@
.group { .group {
display: inline-flex; display: inline-flex;
flex-wrap: nowrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem;
} }
@@ -77,6 +109,7 @@
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
gap: 0.25rem;
align-items: center; align-items: center;
height: 1.75rem; height: 1.75rem;
@@ -14,17 +14,19 @@
interface Props { interface Props {
chatId?: number; chatId?: number;
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folderId?: number | null;
kind: FeedKind; kind: FeedKind;
onopen: (item: FeedItem, messageId: number) => void; onopen: (item: FeedItem, messageId: number) => void;
} }
let { kind, chats = null, chatId, onopen }: Props = $props(); let { kind, chats = null, folderId = null, chatId, onopen }: Props = $props();
const PAGE = 30; const PAGE = 30;
const EMPTY_KEYS = { const EMPTY_KEYS = {
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[]>([]);
@@ -37,6 +39,7 @@
const filter = $derived<FeedQuery>({ const filter = $derived<FeedQuery>({
chat_id: chatId, chat_id: chatId,
chat_kind: chats ?? undefined, chat_kind: chats ?? undefined,
folder_id: folderId ?? undefined,
kind, kind,
limit: PAGE, limit: PAGE,
}); });
@@ -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(() => {
@@ -51,12 +55,19 @@
</header> </header>
<FeedFilters <FeedFilters
chats={filter.chats} chats={filter.chats}
folder={filter.folder}
kind={filter.kind} kind={filter.kind}
onchats={(chats) => feed.filter(page.url, { ...filter, chats })} onchats={(chats) => feed.filter(page.url, { ...filter, chats })}
onfolder={(folder) => feed.filter(page.url, { ...filter, folder })}
onkind={(kind) => feed.filter(page.url, { ...filter, kind })} onkind={(kind) => feed.filter(page.url, { ...filter, kind })}
/> />
<div class="feed-body custom-scroll"> <div class="feed-body custom-scroll">
<FeedList chats={filter.chats} kind={filter.kind} onopen={open} /> <FeedList
chats={filter.chats}
folderId={filter.folder}
kind={filter.kind}
onopen={open}
/>
</div> </div>
</div> </div>
+43 -29
View File
@@ -3,7 +3,10 @@
import type { JobStatus, JobView } from "#lib/api/types.js"; import type { JobStatus, JobView } from "#lib/api/types.js";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.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 { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -14,25 +17,22 @@
const POLL_MS = 2000; const POLL_MS = 2000;
const KIND_LABELS: Record<string, string> = { const KIND_KEYS: Record<string, MessageKey> = {
backfill: "Бэкфилл", backfill: "jobs.kind.backfill",
backfill_stories: "Бэкфилл сторис", backfill_stories: "jobs.kind.backfill_stories",
enrich_chat: "Обогащение чата", enrich_chat: "jobs.kind.enrich_chat",
fetch_avatar: "Аватар", fetch_avatar: "jobs.kind.fetch_avatar",
fetch_custom_emoji: "Кастом-эмодзи", fetch_custom_emoji: "jobs.kind.fetch_custom_emoji",
fetch_media: "Докачка медиа", fetch_media: "jobs.kind.fetch_media",
sync_dialogs: "Синхронизация диалогов", fetch_read_info: "jobs.kind.fetch_read_info",
transcribe: "Расшифровка", sync_contacts: "jobs.kind.sync_contacts",
sync_dialogs: "jobs.kind.sync_dialogs",
transcribe: "jobs.kind.transcribe",
}; };
const STATUS_LABELS: Record<JobStatus, string> = { function statusLabel(status: JobStatus): string {
canceled: "Отменено", return t(`jobs.status.${status}`);
done: "Готово", }
failed: "Ошибка",
paused: "Пауза",
pending: "В очереди",
running: "Выполняется",
};
let jobs = $state<JobView[]>([]); let jobs = $state<JobView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -56,7 +56,7 @@
await cancelJob(job.id); await cancelJob(job.id);
await load(); await load();
} catch { } catch {
toasts.error("Не удалось остановить задачу"); toasts.error(t("jobs.stopFailed"));
} finally { } finally {
canceling = null; canceling = null;
} }
@@ -76,16 +76,22 @@
async function load() { async function load() {
jobs = await listJobs(); jobs = await listJobs();
chats.ensureMany(
jobs.map(chatId).filter((id): id is number => id !== null)
);
loading = false; loading = false;
schedule(); schedule();
} }
function kindLabel(job: JobView): string { function kindLabel(job: JobView): string {
const label = KIND_LABELS[job.kind] ?? job.kind; const key = KIND_KEYS[job.kind];
const kind = key ? t(key) : job.kind;
if (job.kind !== "backfill") { if (job.kind !== "backfill") {
return label; return kind;
} }
return job.params.full ? `${label} (полный)` : `${label} (новые)`; return job.params.full
? t("jobs.kind.full", { kind })
: t("jobs.kind.newer", { kind });
} }
function processed(job: JobView): number | null { function processed(job: JobView): number | null {
@@ -98,6 +104,10 @@
return typeof value === "number" ? value : null; return typeof value === "number" ? value : null;
} }
function chatLabel(id: number): string {
return chats.byId(id)?.title ?? t("jobs.chat", { id });
}
$effect(() => { $effect(() => {
if (version >= 0 && accounts.selectedId !== null) { if (version >= 0 && accounts.selectedId !== null) {
load().catch(() => { load().catch(() => {
@@ -116,10 +126,12 @@
{#if loading} {#if loading}
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if jobs.length === 0} {:else if jobs.length === 0}
<div class="state empty">Задач нет</div> <div class="state empty">{t("jobs.empty")}</div>
{:else} {:else}
<div class="jobs"> <div class="jobs">
{#each jobs as job (job.id)} {#each jobs as job (job.id)}
{@const chat = chatId(job)}
{@const count = processed(job)}
<div class="job"> <div class="job">
<div class="job-head"> <div class="job-head">
<span class="kind">{kindLabel(job)}</span> <span class="kind">{kindLabel(job)}</span>
@@ -130,20 +142,22 @@
onclick={() => cancel(job)} onclick={() => cancel(job)}
type="button" type="button"
> >
Стоп {t("jobs.stop")}
</button> </button>
{/if} {/if}
<span class="badge {job.status}">{STATUS_LABELS[job.status]}</span> <span class="badge {job.status}">{statusLabel(job.status)}</span>
</div> </div>
<div class="meta"> <div class="meta">
{#if chatId(job) !== null} {#if chat !== null}
<span>чат {chatId(job)}</span> <span>{chatLabel(chat)}</span>
{/if} {/if}
{#if processed(job) !== null} {#if count !== null}
<span>обработано {processed(job)}</span> <span>
{t("jobs.processed", { count: count.toLocaleString(locale.tag) })}
</span>
{/if} {/if}
{#if job.flood_waits > 0} {#if job.flood_waits > 0}
<span>flood-wait {job.flood_waits}</span> <span>{t("jobs.floodWaits", { count: job.flood_waits })}</span>
{/if} {/if}
<span class="time">{formatFull(job.created_at)}</span> <span class="time">{formatFull(job.created_at)}</span>
</div> </div>
@@ -8,8 +8,10 @@
import JobList from "#lib/components/jobs/JobList.svelte"; import JobList from "#lib/components/jobs/JobList.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
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 { createChatPicker } from "#lib/stores/chat-picker.svelte.js"; import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -23,6 +25,7 @@
let starting = $state(false); let starting = $state(false);
let syncing = $state(false); let syncing = $state(false);
let syncingContacts = $state(false); let syncingContacts = $state(false);
const started = createFlash<"backfill" | "sync" | "contacts">();
const picker = createChatPicker(); const picker = createChatPicker();
const availableChats = $derived(picker.results); const availableChats = $derived(picker.results);
@@ -39,9 +42,9 @@
function chatTitle(id: number | null): string { function chatTitle(id: number | null): string {
if (id === null) { if (id === null) {
return "Чат не выбран"; return t("jobs.noChat");
} }
return chats.byId(id)?.title ?? `Чат ${id}`; return chats.byId(id)?.title ?? t("ui.chat", { id });
} }
function selectChat(id: number) { function selectChat(id: number) {
@@ -57,10 +60,10 @@
starting = true; starting = true;
try { try {
await enqueueBackfill(selected, media, true); await enqueueBackfill(selected, media, true);
toasts.success("Полный бэкфилл запущен"); started.show("backfill");
version += 1; version += 1;
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("jobs.backfillFailed"));
} finally { } finally {
starting = false; starting = false;
} }
@@ -73,10 +76,10 @@
syncing = true; syncing = true;
try { try {
await syncDialogs(); await syncDialogs();
toasts.success("Синхронизация диалогов запущена"); started.show("sync");
version += 1; version += 1;
} catch { } catch {
toasts.error("Не удалось синхронизировать диалоги"); toasts.error(t("jobs.syncFailed"));
} finally { } finally {
syncing = false; syncing = false;
} }
@@ -89,10 +92,10 @@
syncingContacts = true; syncingContacts = true;
try { try {
await syncContacts(); await syncContacts();
toasts.success("Синхронизация контактов запущена"); started.show("contacts");
version += 1; version += 1;
} catch { } catch {
toasts.error("Не удалось синхронизировать контакты"); toasts.error(t("jobs.contactsFailed"));
} finally { } finally {
syncingContacts = false; syncingContacts = false;
} }
@@ -105,21 +108,20 @@
<div class="panel"> <div class="panel">
<section> <section>
<div class="section-title">Диалоги</div> <div class="section-title">{t("jobs.dialogs")}</div>
<p class="hint"> <p class="hint">{t("jobs.dialogsHint")}</p>
Синхронизация подтягивает все диалоги аккаунта — старые чаты станут видны
и доступны для бэкфилла.
</p>
<div class="action"> <div class="action">
<Button fluid loading={syncing} onclick={sync} variant="secondary"> <Button fluid loading={syncing} onclick={sync} variant="secondary">
<Icon name="reload" /> {#if started.current === "sync"}
<span>Синхронизировать диалоги</span> <Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="reload" />
<span>{t("jobs.syncDialogs")}</span>
{/if}
</Button> </Button>
</div> </div>
<p class="hint"> <p class="hint">{t("jobs.contactsHint")}</p>
Контакты подтягиваются отдельно — тогда в поиске появятся люди, с которыми
ещё не было переписки.
</p>
<div class="action"> <div class="action">
<Button <Button
fluid fluid
@@ -127,18 +129,20 @@
onclick={syncContactList} onclick={syncContactList}
variant="secondary" variant="secondary"
> >
<Icon name="user" /> {#if started.current === "contacts"}
<span>Синхронизировать контакты</span> <Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="user" />
<span>{t("jobs.syncContacts")}</span>
{/if}
</Button> </Button>
</div> </div>
</section> </section>
<section> <section>
<div class="section-title">Бэкфилл</div> <div class="section-title">{t("jobs.backfill")}</div>
<p class="hint"> <p class="hint">{t("jobs.backfillHint")}</p>
Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в
шапке чата догружает только сообщения новее последнего сохранённого.
</p>
<button <button
class="select" class="select"
@@ -155,8 +159,9 @@
{#if picking} {#if picking}
<div class="picker"> <div class="picker">
<input <input
aria-label={t("ui.searchChat")}
class="filter" class="filter"
placeholder="Поиск чата" placeholder={t("ui.searchChat")}
type="text" type="text"
bind:value={filter} bind:value={filter}
> >
@@ -166,7 +171,7 @@
onclick={() => selectChat(chat.chat_id)} onclick={() => selectChat(chat.chat_id)}
type="button" type="button"
> >
{chat.title ?? `Чат ${chat.chat_id}`} {chat.title ?? t("ui.chat", { id: chat.chat_id })}
</button> </button>
{/each} {/each}
</div> </div>
@@ -181,7 +186,7 @@
role="switch" role="switch"
type="button" type="button"
> >
<span class="label">Скачивать медиа</span> <span class="label">{t("jobs.downloadMedia")}</span>
<span class="switch" class:on={media}> <span class="switch" class:on={media}>
<span class="knob"></span> <span class="knob"></span>
</span> </span>
@@ -195,14 +200,19 @@
onclick={start} onclick={start}
variant="primary" variant="primary"
> >
<Icon name="cloud-download" /> {#if started.current === "backfill"}
<span>Запустить полный бэкфилл</span> <Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="cloud-download" />
<span>{t("jobs.startBackfill")}</span>
{/if}
</Button> </Button>
</div> </div>
</section> </section>
<section> <section>
<div class="section-title">Задачи</div> <div class="section-title">{t("jobs.title")}</div>
<JobList {version} /> <JobList {version} />
</section> </section>
</div> </div>
@@ -9,7 +9,9 @@
import type { MediaRef } from "#lib/api/types.js"; import type { MediaRef } from "#lib/api/types.js";
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 { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -23,7 +25,17 @@
let loaded = $state(false); let loaded = $state(false);
let inline = $state<InlineMedia | null>(null); let inline = $state<InlineMedia | null>(null);
let queuing = $state(false); let queuing = $state(false);
const queued = createFlash<true>();
const queueIcon = $derived.by(() => {
if (queuing) {
return "timer";
}
return queued.current ? "check" : "download";
});
const queueLabel = $derived(
queued.current ? t("chat.media.queued") : t("media.downloadToArchive")
);
const ready = $derived(inline?.state === "ready" ? inline : null); const ready = $derived(inline?.state === "ready" ? inline : null);
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false); const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
@@ -39,9 +51,9 @@
queuing = true; queuing = true;
try { try {
await fetchMedia(chatId, media.message_id); await fetchMedia(chatId, media.message_id);
toasts.success("Download queued"); queued.show(true);
} catch { } catch {
toasts.error("Failed to queue download"); toasts.error(t("media.queueFailed"));
} finally { } finally {
queuing = false; queuing = false;
} }
@@ -62,11 +74,17 @@
</button> </button>
{:else if ready} {:else if ready}
<button class="tile" onclick={onopen} type="button"> <button class="tile" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}> <img alt={t("media.attachment")} src={ready.url}>
</button> </button>
{:else if inline?.state === "not-downloaded"} {:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button"> <button
<Icon name={queuing ? "timer" : "download"} size="1.5rem" /> aria-label={queueLabel}
class="tile placeholder"
onclick={queue}
title={queueLabel}
type="button"
>
<Icon name={queueIcon} size="1.5rem" />
</button> </button>
{:else if loaded} {:else if loaded}
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div> <div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
interface Props { interface Props {
@@ -41,7 +42,12 @@
</script> </script>
<div class="Audio" class:own> <div class="Audio" class:own>
<button aria-label="Play audio" class="toggle" onclick={toggle} type="button"> <button
aria-label={paused ? t("ui.play") : t("ui.pause")}
class="toggle"
onclick={toggle}
type="button"
>
<Icon name={paused ? "play" : "pause"} size="1.625rem" /> <Icon name={paused ? "play" : "pause"} size="1.625rem" />
</button> </button>
<div class="content"> <div class="content">
@@ -82,7 +88,7 @@
padding: 0.1875rem 0; padding: 0.1875rem 0;
&.own { &.own {
--active: var(--color-white); --active: var(--color-accent-own);
--toggle-fg: var(--color-background-own); --toggle-fg: var(--color-background-own);
} }
} }
@@ -3,6 +3,7 @@
import type { MediaRef } from "#lib/api/types.js"; import type { MediaRef } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -16,7 +17,7 @@
let saving = $state(false); let saving = $state(false);
const name = $derived( const name = $derived(
media.file_name ?? mediaKindLabel(media.kind) ?? "Файл" media.file_name ?? mediaKindLabel(media.kind) ?? t("media.kind.document")
); );
const size = $derived(formatBytes(media.file_size)); const size = $derived(formatBytes(media.file_size));
@@ -28,7 +29,7 @@
try { try {
await downloadMedia(media.id); await downloadMedia(media.id);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("ui.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -52,7 +53,7 @@
</span> </span>
</button> </button>
<button <button
aria-label="Доступ по ссылке" aria-label={t("ui.shareLink")}
class="file-share" class="file-share"
onclick={share} onclick={share}
type="button" type="button"
@@ -1,17 +1,37 @@
<script lang="ts"> <script lang="ts">
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 { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { POSTER_TIME, poster } from "#lib/media/poster.js"; import { POSTER_TIME, poster } from "#lib/media/poster.js";
import { Transcript } from "#lib/media/transcript.svelte.js";
interface Props { interface Props {
chatId: number;
length?: number | null;
messageId: number;
ondownload?: () => void;
transcript?: string | null; transcript?: string | null;
url: string; url: string | null;
waiting?: boolean;
} }
let { url, transcript = null }: Props = $props(); let {
url,
transcript = null,
chatId,
messageId,
length = null,
ondownload,
waiting = false,
}: Props = $props();
let showTranscript = $state(false); const stt = new Transcript(
() => chatId,
() => messageId,
() => transcript
);
const RADIUS = 94; const RADIUS = 94;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@@ -23,8 +43,21 @@
const progress = $derived(duration > 0 ? currentTime / duration : 0); const progress = $derived(duration > 0 ? currentTime / duration : 0);
const remaining = $derived(Math.max(0, duration - currentTime)); const remaining = $derived(Math.max(0, duration - currentTime));
const shownTime = $derived(
paused && currentTime <= POSTER_TIME ? duration || (length ?? 0) : remaining
);
const toggleLabel = $derived.by(() => {
if (!url) {
return t("media.downloadToArchive");
}
return paused ? t("media.playRound") : t("ui.pause");
});
function toggle() { function toggle() {
if (!url) {
ondownload?.();
return;
}
if (!element) { if (!element) {
return; return;
} }
@@ -38,60 +71,71 @@
<div class="RoundVideoWrap"> <div class="RoundVideoWrap">
<button <button
aria-label="Play video message" aria-label={toggleLabel}
class="RoundVideo" class="RoundVideo"
onclick={toggle} onclick={toggle}
type="button" type="button"
> >
<!-- svelte-ignore a11y_media_has_caption --> {#if url}
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions --> <!-- svelte-ignore a11y_media_has_caption -->
<video <!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
onended={() => element && releasePlayback(element)} <video
onplay={() => element && claimPlayback(element)} onended={() => element && releasePlayback(element)}
playsinline onplay={() => element && claimPlayback(element)}
preload="auto" playsinline
src={url} preload="auto"
bind:this={element} src={url}
bind:currentTime bind:this={element}
bind:duration bind:currentTime
bind:paused bind:duration
use:poster bind:paused
></video> use:poster
<svg aria-hidden="true" class="RoundVideoRing" viewBox="0 0 200 200"> ></video>
<circle <svg aria-hidden="true" class="RoundVideoRing" viewBox="0 0 200 200">
class="RoundVideoProgress" <circle
cx="100" class="RoundVideoProgress"
cy="100" cx="100"
r={RADIUS} cy="100"
stroke-dasharray={CIRCUMFERENCE} r={RADIUS}
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)} stroke-dasharray={CIRCUMFERENCE}
/> stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
</svg> />
{#if paused} </svg>
<span class="play"><Icon name="large-play" size="2.75rem" /></span> {#if paused}
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
{/if}
{:else}
<span class="blank"></span>
<span class="play fetch">
<Icon name={waiting ? "timer" : "download"} size="1.75rem" />
</span>
{/if} {/if}
<span class="badge"> <span class="badge">
<Icon name="microphone" size="0.875rem" /> <Icon name="microphone" size="0.875rem" />
{formatDuration( {formatDuration(shownTime)}
paused && currentTime <= POSTER_TIME ? duration : remaining
)}
</span> </span>
</button> </button>
{#if transcript} <button
<button aria-label={stt.open
class="transcribe" ? t("media.hideTranscription")
onclick={() => { : t("media.showTranscription")}
showTranscript = !showTranscript; class="transcribe"
}} disabled={stt.pending}
type="button" onclick={() => stt.toggle()}
class:active={showTranscript} type="button"
> class:active={stt.open}
>
{#if stt.pending}
<Spinner color="gray" size="1rem" />
{:else}
<Icon name="transcribe" size="1rem" /> <Icon name="transcribe" size="1rem" />
Transcription
</button>
{#if showTranscript}
<div class="transcript">{transcript}</div>
{/if} {/if}
{t("media.transcription")}
</button>
{#if stt.open}
<div class="transcript">{stt.text}</div>
{:else if stt.failure}
<div class="transcript failed">{stt.failure}</div>
{/if} {/if}
</div> </div>
@@ -122,6 +166,10 @@
&.active { &.active {
color: var(--color-primary); color: var(--color-primary);
} }
&:disabled {
cursor: default;
}
} }
.transcript { .transcript {
@@ -132,6 +180,11 @@
color: var(--color-text); color: var(--color-text);
white-space: pre-wrap; white-space: pre-wrap;
} }
.failed {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.RoundVideo { .RoundVideo {
cursor: pointer; cursor: pointer;
@@ -149,6 +202,16 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.blank {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--color-default-shadow);
}
video { video {
display: block; display: block;
@@ -190,6 +253,13 @@
color: var(--color-white); color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)); filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
&.fetch {
padding: 0.875rem;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.45);
filter: none;
}
} }
.badge { .badge {
@@ -1,51 +1,39 @@
<script lang="ts"> <script lang="ts">
import { requestTranscription } from "#lib/api/media.js";
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 { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { Transcript } from "#lib/media/transcript.svelte.js";
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js"; import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
chatId: number; chatId: number;
length?: number | null;
messageId: number; messageId: number;
ondownload?: () => void;
own: boolean; own: boolean;
transcript?: string | null; transcript?: string | null;
url: string; url: string | null;
waiting?: boolean;
} }
let { url, own, transcript = null, chatId, messageId }: Props = $props(); let {
url,
own,
transcript = null,
chatId,
messageId,
length = null,
ondownload,
waiting = false,
}: Props = $props();
let fetched = $state<string | null>(null); const stt = new Transcript(
let pending = $state(false); () => chatId,
const text = $derived(fetched ?? transcript); () => messageId,
() => transcript
let showTranscript = $state(false); );
async function onTranscribe() {
if (text) {
showTranscript = !showTranscript;
return;
}
if (pending) {
return;
}
pending = true;
try {
const result = await requestTranscription(chatId, messageId);
if (result) {
fetched = result;
showTranscript = true;
} else {
toasts.error("Не удалось расшифровать");
}
} catch {
toasts.error("Не удалось расшифровать");
} finally {
pending = false;
}
}
let element = $state<HTMLAudioElement>(); let element = $state<HTMLAudioElement>();
let peaks = $state<number[]>(flatWaveform()); let peaks = $state<number[]>(flatWaveform());
let currentTime = $state(0); let currentTime = $state(0);
@@ -53,11 +41,20 @@
let paused = $state(true); let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0); const progress = $derived(duration > 0 ? currentTime / duration : 0);
const toggleLabel = $derived.by(() => {
if (!url) {
return t("media.downloadToArchive");
}
return paused ? t("media.playVoice") : t("ui.pause");
});
const elapsed = $derived( const elapsed = $derived(
paused && currentTime === 0 ? duration : currentTime paused && currentTime === 0 ? duration || (length ?? 0) : currentTime
); );
$effect(() => { $effect(() => {
if (!url) {
return;
}
let active = true; let active = true;
computeWaveform(url) computeWaveform(url)
.then((result) => { .then((result) => {
@@ -72,6 +69,10 @@
}); });
function toggle() { function toggle() {
if (!url) {
ondownload?.();
return;
}
if (!element) { if (!element) {
return; return;
} }
@@ -96,16 +97,20 @@
<div class="VoiceWrap" class:own> <div class="VoiceWrap" class:own>
<div class="Voice"> <div class="Voice">
<button <button
aria-label="Play voice" aria-label={toggleLabel}
class="toggle" class="toggle"
onclick={toggle} onclick={toggle}
type="button" type="button"
> >
<Icon {#if !url}
class={paused ? "nudge" : ""} <Icon name={waiting ? "timer" : "download"} size="1.5rem" />
name={paused ? "play" : "pause"} {:else}
size="1.5rem" <Icon
/> class={paused ? "nudge" : ""}
name={paused ? "play" : "pause"}
size="1.5rem"
/>
{/if}
</button> </button>
<div class="body"> <div class="body">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -121,33 +126,39 @@
<div class="time">{formatDuration(elapsed)}</div> <div class="time">{formatDuration(elapsed)}</div>
</div> </div>
<button <button
aria-label="Show transcription" aria-label={stt.open
? t("media.hideTranscription")
: t("media.showTranscription")}
class="transcribe" class="transcribe"
disabled={pending} disabled={stt.pending}
onclick={onTranscribe} onclick={() => stt.toggle()}
type="button" type="button"
class:active={showTranscript && Boolean(text)} class:active={stt.open}
> >
{#if pending} {#if stt.pending}
<Spinner color={own ? "white" : "gray"} size="1rem" /> <Spinner color={own ? "white" : "gray"} size="1rem" />
{:else} {:else}
<Icon name="transcribe" size="1.125rem" /> <Icon name="transcribe" size="1.125rem" />
{/if} {/if}
</button> </button>
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions --> {#if url}
<audio <!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
onended={() => element && releasePlayback(element)} <audio
onplay={() => element && claimPlayback(element)} onended={() => element && releasePlayback(element)}
preload="metadata" onplay={() => element && claimPlayback(element)}
src={url} preload="metadata"
bind:this={element} src={url}
bind:currentTime bind:this={element}
bind:duration bind:currentTime
bind:paused bind:duration
></audio> bind:paused
></audio>
{/if}
</div> </div>
{#if text && showTranscript} {#if stt.open}
<div class="transcript">{text}</div> <div class="transcript">{stt.text}</div>
{:else if stt.failure}
<div class="transcript failed">{stt.failure}</div>
{/if} {/if}
</div> </div>
@@ -160,11 +171,11 @@
--transcribe-bg: var(--color-primary-tint); --transcribe-bg: var(--color-primary-tint);
&.own { &.own {
--active: var(--color-white); --active: var(--color-accent-own);
--inactive: rgba(255, 255, 255, 0.45); --inactive: color-mix(in srgb, var(--color-accent-own) 40%, transparent);
--toggle-fg: var(--color-background-own); --toggle-fg: var(--color-background-own);
--voice-meta: rgba(255, 255, 255, 0.75); --voice-meta: var(--color-message-meta-own);
--transcribe-bg: rgba(255, 255, 255, 0.2); --transcribe-bg: var(--color-voice-transcribe-button-own);
} }
} }
@@ -214,6 +225,11 @@
color: var(--color-text); color: var(--color-text);
white-space: pre-wrap; white-space: pre-wrap;
} }
.failed {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.toggle { .toggle {
cursor: pointer; cursor: pointer;
@@ -24,9 +24,11 @@
sameToggles, sameToggles,
summary, summary,
TOGGLE_KEYS, TOGGLE_KEYS,
TOGGLE_LABELS,
type ToggleKey, type ToggleKey,
toggleLabel,
} from "#lib/format/policy.js"; } from "#lib/format/policy.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
interface Props { interface Props {
onchange?: () => void; onchange?: () => void;
@@ -44,17 +46,17 @@
onchange, onchange,
}: Props = $props(); }: Props = $props();
const PRESETS: { label: string; toggles: CaptureToggles }[] = [ const PRESETS: { label: MessageKey; toggles: CaptureToggles }[] = [
{ label: "Всё", toggles: preset("all") }, { label: "policy.summary.all", toggles: preset("all") },
{ label: "Только сообщения", toggles: preset(["messages"]) }, { label: "policy.summary.messagesOnly", toggles: preset(["messages"]) },
{ label: "Ничего", toggles: preset([]) }, { label: "policy.summary.none", toggles: preset([]) },
]; ];
let rule = $state<PolicyRecord | null>(null); let rule = $state<PolicyRecord | null>(null);
let parent = $state<PolicyParent | null>(null); let parent = $state<PolicyParent | null>(null);
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
let removing = $state(false); let removing = $state(false);
const isChat = $derived(scopeType === "chat"); const isChat = $derived(scopeType === "chat");
@@ -79,13 +81,14 @@
if (isChat && parent) { if (isChat && parent) {
const from = parentLabel(parent.scope_type, parent.title); const from = parentLabel(parent.scope_type, parent.title);
return rule return rule
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.` ? t("policy.hint.chatRule", {
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`; from,
summary: summary(parent.toggles).toLocaleLowerCase(locale.tag),
})
: t("policy.hint.chatInherit", { from });
} }
if (isFolder) { if (isFolder) {
return rule return rule ? t("policy.hint.folderRule") : t("policy.hint.folderNone");
? "Действует на чаты папки, у которых нет своего правила."
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
} }
return ""; return "";
} }
@@ -112,7 +115,7 @@
}); });
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось сохранить правило"; problem = "policy.rule.saveFailed";
} finally { } finally {
saving = false; saving = false;
} }
@@ -136,7 +139,7 @@
removing = false; removing = false;
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось удалить правило"; problem = "policy.rule.deleteFailed";
} finally { } finally {
saving = false; saving = false;
} }
@@ -153,7 +156,7 @@
try { try {
({ rule, parent } = await getPolicyScope(target)); ({ rule, parent } = await getPolicyScope(target));
} catch { } catch {
problem = "Не удалось загрузить правило"; problem = "policy.rule.loadFailed";
} finally { } finally {
loading = false; loading = false;
} }
@@ -179,7 +182,7 @@
type="button" type="button"
class:on={shown !== null && sameToggles(shown, item.toggles)} class:on={shown !== null && sameToggles(shown, item.toggles)}
> >
{item.label} {t(item.label)}
</button> </button>
{/each} {/each}
</div> </div>
@@ -196,7 +199,7 @@
type="button" type="button"
class:changed={differs(key)} class:changed={differs(key)}
> >
<span class="label">{TOGGLE_LABELS[key]}</span> <span class="label">{toggleLabel(key)}</span>
<span class="switch" class:on={shown[key]}> <span class="switch" class:on={shown[key]}>
<span class="knob"></span> <span class="knob"></span>
</span> </span>
@@ -206,7 +209,7 @@
{/if} {/if}
{#if problem} {#if problem}
<p class="problem">{problem}</p> <p class="problem">{t(problem)}</p>
{/if} {/if}
{#if target} {#if target}
@@ -216,7 +219,7 @@
{#if rule && (isChat || isFolder)} {#if rule && (isChat || isFolder)}
{#if removing} {#if removing}
<div class="removal"> <div class="removal">
<span>Удалить правило?</span> <span>{t("policy.rule.removeConfirm")}</span>
<Button <Button
onclick={() => { onclick={() => {
removing = false; removing = false;
@@ -224,7 +227,7 @@
smaller smaller
variant="text" variant="text"
> >
Отмена {t("common.cancel")}
</Button> </Button>
<Button <Button
disabled={saving} disabled={saving}
@@ -233,7 +236,7 @@
smaller smaller
variant="danger" variant="danger"
> >
Удалить {t("common.delete")}
</Button> </Button>
</div> </div>
{:else} {:else}
@@ -244,7 +247,7 @@
}} }}
type="button" type="button"
> >
Удалить правило {t("policy.rule.remove")}
</button> </button>
{/if} {/if}
{/if} {/if}
@@ -2,6 +2,7 @@
import { Dialog } from "bits-ui"; import { Dialog } from "bits-ui";
import PolicyCard from "#lib/components/policy/PolicyCard.svelte"; import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
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 { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
const target = $derived(policyUi.target); const target = $derived(policyUi.target);
@@ -22,10 +23,10 @@
<div class="heading"> <div class="heading">
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title> <Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
<span class="kind"> <span class="kind">
{isChat ? "Политика захвата чата" : "Политика захвата папки"} {isChat ? t("policy.kind.chat") : t("policy.kind.folder")}
</span> </span>
</div> </div>
<Dialog.Close aria-label="Закрыть" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more