Compare commits

...
10 Commits
36 changed files with 2782 additions and 115 deletions

No files matched your search

+2 -2
View File
@@ -19,12 +19,12 @@ Nothing is ever deleted from the archive.
## Parts ## Parts
- `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files). - `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files).
- `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, search, stories, presence graphs. - `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, a feed of what was deleted or edited across chats, search, stories, presence graphs.
- `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`. - `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`.
## 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_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-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.
## Run ## Run
@@ -0,0 +1,32 @@
"""edit versions everywhere
Revision ID: a2c4e6f8b1d3
Revises: e8b3f1a6c2d4
Create Date: 2026-10-19 02:30:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "a2c4e6f8b1d3"
down_revision: str | None = "e8b3f1a6c2d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"UPDATE capture_policy SET track_edits_deletes = TRUE, updated_at = now() "
"WHERE scope_type IN ('default_group', 'default_channel') "
"AND NOT track_edits_deletes"
)
def downgrade() -> None:
op.execute(
"UPDATE capture_policy SET track_edits_deletes = FALSE, updated_at = now() "
"WHERE scope_type IN ('default_group', 'default_channel') "
"AND track_edits_deletes"
)
@@ -0,0 +1,198 @@
"""feed of deleted and edited messages
Revision ID: b3d5f7a9c2e4
Revises: a2c4e6f8b1d3
Create Date: 2026-10-19 02:40:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b3d5f7a9c2e4"
down_revision: str | None = "a2c4e6f8b1d3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ITEMS = """
CREATE TABLE feed_items (
id bigserial PRIMARY KEY,
account_id integer NOT NULL,
kind text NOT NULL,
chat_id bigint NOT NULL,
chat_kind text NOT NULL,
message_id bigint NOT NULL,
message_date timestamptz,
at timestamptz NOT NULL,
since timestamptz NOT NULL,
message_count integer NOT NULL DEFAULT 1
)
"""
_SEEN = """
CREATE TABLE feed_seen (
account_id integer PRIMARY KEY,
seen_at timestamptz NOT NULL
)
"""
_CHAT_KIND = """
CREATE FUNCTION feed_chat_kind(chat_id bigint, chat_type text)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT CASE
WHEN chat_id > 0 THEN 'dm'
WHEN chat_type = 'ChatType.CHANNEL' THEN 'channel'
ELSE 'group' END
$$
"""
_MESSAGE_DATE = """
CREATE FUNCTION feed_message_date(stamp text)
RETURNS timestamptz LANGUAGE sql STABLE AS $$
SELECT CASE
WHEN stamp ~ '^\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$'
THEN stamp::timestamp AT TIME ZONE 'UTC' END
$$
"""
_ON_DELETE = """
CREATE FUNCTION feed_on_delete() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
item feed_items%ROWTYPE;
BEGIN
SELECT * INTO item FROM feed_items
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
AND kind = 'deleted'
ORDER BY at DESC, id DESC LIMIT 1;
IF FOUND AND NEW.deleted_at BETWEEN item.since - interval '5 seconds'
AND item.at + interval '5 seconds' THEN
IF (NEW.date, NEW.message_id) > (item.message_date, item.message_id) THEN
item.message_id := NEW.message_id;
item.message_date := NEW.date;
END IF;
UPDATE feed_items SET
message_count = message_count + 1,
at = greatest(at, NEW.deleted_at),
since = least(since, NEW.deleted_at),
message_id = item.message_id,
message_date = item.message_date
WHERE id = item.id;
ELSE
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date,
at, since)
VALUES (NEW.account_id, 'deleted', NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
NEW.message_id, NEW.date, NEW.deleted_at, NEW.deleted_at);
END IF;
RETURN NULL;
END;
$$
"""
_ON_VERSION = """
CREATE FUNCTION feed_on_version() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.edit_date IS NULL AND NOT EXISTS (
SELECT 1 FROM message_versions v
WHERE v.account_id = NEW.account_id AND v.chat_id = NEW.chat_id
AND v.message_id = NEW.message_id AND v.observed_at < NEW.observed_at
) THEN
RETURN NULL;
END IF;
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since)
VALUES (NEW.account_id, 'edited', NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
NEW.message_id, feed_message_date(NEW.raw->>'date'),
NEW.observed_at, NEW.observed_at);
RETURN NULL;
END;
$$
"""
_BACKFILL = """
WITH d AS (
SELECT account_id, chat_id, message_id, date, deleted_at,
raw->'chat'->>'type' AS chat_type,
deleted_at - lag(deleted_at) OVER (
PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id
) AS gap
FROM messages WHERE deleted_at IS NOT NULL
), b AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '5 seconds')
OVER (PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id)
AS burst
FROM d
), deleted AS (
SELECT account_id, 'deleted' AS kind, chat_id,
feed_chat_kind(chat_id,
(array_agg(chat_type ORDER BY date DESC, message_id DESC))[1])
AS chat_kind,
(array_agg(message_id ORDER BY date DESC, message_id DESC))[1]
AS message_id,
max(date) AS message_date, max(deleted_at) AS at,
min(deleted_at) AS since, count(*)::integer AS message_count
FROM b GROUP BY account_id, chat_id, burst
), edited AS (
SELECT v.account_id, 'edited' AS kind, v.chat_id,
feed_chat_kind(v.chat_id, v.raw->'chat'->>'type') AS chat_kind,
v.message_id, feed_message_date(v.raw->>'date') AS message_date,
v.observed_at AS at, v.observed_at AS since, 1 AS message_count
FROM message_versions v
WHERE v.edit_date IS NOT NULL OR EXISTS (
SELECT 1 FROM message_versions p
WHERE p.account_id = v.account_id AND p.chat_id = v.chat_id
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
)
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since,
message_count)
SELECT * FROM (SELECT * FROM deleted UNION ALL SELECT * FROM edited) items
ORDER BY at, chat_id, message_id
"""
def upgrade() -> None:
op.execute("SET LOCAL jit = off")
op.execute(_ITEMS)
op.execute(_SEEN)
op.execute(_CHAT_KIND)
op.execute(_MESSAGE_DATE)
op.execute(_ON_DELETE)
op.execute(_ON_VERSION)
op.execute(
"CREATE TRIGGER messages_feed_deleted AFTER UPDATE OF deleted_at "
"ON messages FOR EACH ROW "
"WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL) "
"EXECUTE FUNCTION feed_on_delete()"
)
op.execute(
"CREATE TRIGGER message_versions_feed AFTER INSERT ON message_versions "
"FOR EACH ROW EXECUTE FUNCTION feed_on_version()"
)
op.execute(_BACKFILL)
op.execute(
"CREATE INDEX ix_feed_items_recent ON feed_items (account_id, at DESC, id DESC)"
)
op.execute(
"CREATE INDEX ix_feed_items_chat ON feed_items "
"(account_id, chat_id, at DESC, id DESC)"
)
op.execute(
"INSERT INTO feed_seen (account_id, seen_at) SELECT account_id, now() "
"FROM accounts"
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS message_versions_feed ON message_versions")
op.execute("DROP TRIGGER IF EXISTS messages_feed_deleted ON messages")
op.execute("DROP FUNCTION IF EXISTS feed_on_version()")
op.execute("DROP FUNCTION IF EXISTS feed_on_delete()")
op.execute("DROP FUNCTION IF EXISTS feed_message_date(text)")
op.execute("DROP FUNCTION IF EXISTS feed_chat_kind(bigint, text)")
op.execute("DROP TABLE IF EXISTS feed_seen")
op.execute("DROP TABLE IF EXISTS feed_items")
+2
View File
@@ -25,6 +25,7 @@ from api.routers import (
custom_emoji, custom_emoji,
discover, discover,
events, events,
feed,
files, files,
folders, folders,
media, media,
@@ -96,6 +97,7 @@ app.include_router(folders.router)
app.include_router(backfill.router) app.include_router(backfill.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(chats.router) app.include_router(chats.router)
app.include_router(feed.router)
app.include_router(media.router) app.include_router(media.router)
app.include_router(avatars.router) app.include_router(avatars.router)
app.include_router(custom_emoji.router) app.include_router(custom_emoji.router)
+39 -2
View File
@@ -1,3 +1,4 @@
import re
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -23,6 +24,7 @@ from utils.files import resolve_mime
from utils.read import ( from utils.read import (
annotations, annotations,
chats, chats,
feed,
media, media,
peers, peers,
people, people,
@@ -32,6 +34,7 @@ from utils.read import (
watches, watches,
) )
from utils.read.accounts import self_user_id, unknown_account_hint from utils.read.accounts import self_user_id, unknown_account_hint
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
@@ -73,8 +76,10 @@ Typical flows:
transcripts; supports chat/sender/date filters and regex). transcripts; supports chat/sender/date filters and 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_deleted_messages` and `get_message_versions` recover content - Forensics: `get_feed` lists what was deleted or edited in Telegram across
removed or edited in Telegram but kept in the archive. 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
`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
@@ -255,6 +260,38 @@ async def get_deleted_messages(
) )
@mcp.tool
async def get_feed(
account_id: int,
kind: FeedKind = FeedKind.ALL,
since: datetime | None = None,
chat_id: int | None = None,
chat_kind: FeedChatKind | None = None,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> dict[str, Any]:
"""Deleted and edited messages across chats, newest first.
`kind` picks deleted, edited or all; `since` keeps items at or after that
time; `chat_id` or `chat_kind` (dm, group, channel) narrow it down. Edited
items carry `before` and `after` texts. A deleted item with
`message_count` above 1 is a bulk deletion in one chat; read its messages
with `get_deleted_messages` for that chat. Pass `next_cursor` back as
`cursor` for the next page.
"""
if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None:
msg = "cursor must be a next_cursor returned by get_feed"
raise ToolError(msg)
page = await feed.list_feed(
await _pool(),
account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
limit=limit,
cursor=cursor,
)
return page.model_dump(mode="json")
@mcp.tool @mcp.tool
async def get_message_versions( async def get_message_versions(
account_id: int, chat_id: int, message_id: int account_id: int, chat_id: int, message_id: int
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.read import feed
from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
from utils.read.models import DEFAULT_LIMIT, FeedMessagesPage, FeedPage, FeedUnseen
router = APIRouter(prefix="/api/feed", tags=["feed"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
Limit = Annotated[int, Query(ge=1)]
Cursor = Annotated[str | None, Query(pattern=CURSOR_PATTERN)]
class SeenRequest(BaseModel):
account_id: int
at: datetime | None = None
@router.get("")
async def list_feed(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT,
) -> FeedPage:
return await feed.list_feed(
pool,
account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
limit=limit,
cursor=cursor,
)
@router.get("/unseen")
async def feed_unseen(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> FeedUnseen:
return await feed.unseen(pool, account_id)
@router.post("/seen")
async def feed_seen(pool: FromDishka[asyncpg.Pool], body: SeenRequest) -> FeedUnseen:
return await feed.mark_seen(pool, body.account_id, body.at)
@router.get("/{item_id}/messages")
async def feed_item_messages(
pool: FromDishka[asyncpg.Pool],
item_id: int,
account_id: AccountId,
cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT,
) -> FeedMessagesPage:
page = await feed.item_messages(
pool, account_id, item_id, limit=limit, cursor=cursor
)
if page is None:
raise HTTPException(status_code=404, detail="Feed item not found")
return page
+1 -1
View File
@@ -25,7 +25,7 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
meta = meta_from_chat(chat, ctx.contacts.ids) meta = meta_from_chat(chat, ctx.contacts.ids)
await ctx.watches.on_edit(seen_message(chat_id, message)) await ctx.watches.on_edit(seen_message(chat_id, message))
toggles = ctx.resolve(meta) toggles = ctx.resolve(meta)
if not toggles.track_edits_deletes: if not (toggles.messages and toggles.track_edits_deletes):
return return
changed = await repository.add_version( changed = await repository.add_version(
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date) ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
+2 -2
View File
@@ -1,8 +1,8 @@
from utils.policy.models import CaptureToggles, ChatKind from utils.policy.models import CaptureToggles, ChatKind
DEFAULTS: dict[ChatKind, CaptureToggles] = { DEFAULTS: dict[ChatKind, CaptureToggles] = {
ChatKind.CHANNEL: CaptureToggles(), ChatKind.CHANNEL: CaptureToggles(track_edits_deletes=True),
ChatKind.GROUP: CaptureToggles(messages=True), ChatKind.GROUP: CaptureToggles(messages=True, track_edits_deletes=True),
ChatKind.DM: CaptureToggles( ChatKind.DM: CaptureToggles(
messages=True, messages=True,
media=True, media=True,
+69
View File
@@ -1,3 +1,5 @@
from datetime import datetime
import asyncpg import asyncpg
from utils.policy.models import FolderSpec from utils.policy.models import FolderSpec
@@ -331,6 +333,12 @@ async def get_deleted_messages(
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}", f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params, *params,
) )
return await _single_views(pool, account_id, rows)
async def _single_views(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> list[MessageView]:
media_by_key = await _media_map(pool, account_id, rows) media_by_key = await _media_map(pool, account_id, rows)
views: list[MessageView] = [] views: list[MessageView] = []
for row in rows: for row in rows:
@@ -341,6 +349,67 @@ async def get_deleted_messages(
return views return views
async def _rows_at(
pool: asyncpg.Pool, account_id: int, keys: list[tuple[int, int, datetime]]
) -> list[asyncpg.Record]:
if not keys:
return []
params: list[object] = [account_id]
clauses: list[str] = []
for key in keys:
params.extend(key)
n = len(params)
clauses.append(
f"(chat_id = ${n - 2} AND message_id = ${n - 1} AND date = ${n})"
)
return await pool.fetch(
f"SELECT {_MESSAGE_COLS} FROM messages " # noqa: S608
f"WHERE account_id = $1 AND ({' OR '.join(clauses)})",
*params,
)
async def get_messages_at(
pool: asyncpg.Pool, account_id: int, keys: list[tuple[int, int, datetime | None]]
) -> dict[tuple[int, int], MessageView]:
dated = list(
dict.fromkeys((chat, message, date) for chat, message, date in keys if date)
)
rows = await _rows_at(pool, account_id, dated)
views = {
(view.chat_id, view.message_id): view
for view in await _single_views(pool, account_id, rows)
}
for chat_id, message_id, _ in keys:
if (chat_id, message_id) not in views:
view = await get_message(pool, account_id, chat_id, message_id)
if view is not None:
views[chat_id, message_id] = view
return views
async def get_deleted_between( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
since: datetime,
until: datetime,
limit: int,
before: tuple[datetime, int] | None = None,
) -> list[MessageView]:
params: list[object] = [account_id, chat_id, since, until, limit]
where = "account_id = $1 AND chat_id = $2 AND deleted_at BETWEEN $3 AND $4"
if before is not None:
params.extend(before)
where += " AND (date, message_id) < ($6, $7)"
rows = await pool.fetch(
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
"ORDER BY date DESC, message_id DESC LIMIT $5",
*params,
)
return await _single_views(pool, account_id, rows)
async def get_message_versions( async def get_message_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[MessageVersionView]: ) -> list[MessageVersionView]:
+277
View File
@@ -0,0 +1,277 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
import asyncpg
from utils.read import chats, peers
from utils.read.accounts import self_user_id
from utils.read.models import (
DEFAULT_LIMIT,
MAX_LIMIT,
ChatListItem,
FeedChat,
FeedItem,
FeedMessagesPage,
FeedPage,
FeedUnseen,
MessageView,
PeerView,
)
CURSOR_PATTERN = r"^\d+\.\d+$"
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1)
_ITEM_COLS = (
"id, kind, chat_id, chat_kind, message_id, message_date, at, since, message_count"
)
_VERSIONS = """
SELECT v.chat_id, v.message_id, v.observed_at, v.edit_date, v.text,
p.text AS before, p.observed_at IS NOT NULL AS has_before
FROM unnest($2::bigint[], $3::bigint[], $4::timestamptz[])
AS k(chat_id, message_id, observed_at)
JOIN message_versions v
ON v.account_id = $1 AND v.chat_id = k.chat_id
AND v.message_id = k.message_id AND v.observed_at = k.observed_at
LEFT JOIN LATERAL (
SELECT text, observed_at FROM message_versions p
WHERE p.account_id = $1 AND p.chat_id = v.chat_id
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
ORDER BY p.observed_at DESC LIMIT 1
) p ON true
"""
_UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited
FROM feed_items
WHERE account_id = $1 AND at > coalesce($2, '-infinity'::timestamptz)
"""
_MARK_SEEN = """
INSERT INTO feed_seen (account_id, seen_at) VALUES ($1, coalesce($2, now()))
ON CONFLICT (account_id) DO UPDATE
SET seen_at = greatest(feed_seen.seen_at, EXCLUDED.seen_at)
"""
class FeedKind(StrEnum):
ALL = "all"
DELETED = "deleted"
EDITED = "edited"
class FeedChatKind(StrEnum):
DM = "dm"
GROUP = "group"
CHANNEL = "channel"
@dataclass(slots=True)
class FeedFilter:
kind: FeedKind = FeedKind.ALL
chat_id: int | None = None
chat_kind: FeedChatKind | None = None
since: datetime | None = None
def encode_cursor(at: datetime, key: int) -> str:
return f"{(at - _EPOCH) // _MICROSECOND}.{key}"
def decode_cursor(cursor: str) -> tuple[datetime, int]:
micros, _, key = cursor.partition(".")
return _EPOCH + int(micros) * _MICROSECOND, int(key)
def _where(
account_id: int, feed: FeedFilter, cursor: str | None
) -> tuple[str, list[object]]:
params: list[object] = [account_id]
clauses = ["account_id = $1"]
conditions = (
("kind = {}", None if feed.kind is FeedKind.ALL else feed.kind.value),
("chat_id = {}", feed.chat_id),
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
("at >= {}", feed.since),
)
for template, value in conditions:
if value is not None:
params.append(value)
clauses.append(template.format(f"${len(params)}"))
if cursor is not None:
params.extend(decode_cursor(cursor))
clauses.append(f"(at, id) < (${len(params) - 1}, ${len(params)})")
return " AND ".join(clauses), params
def _peer_name(peer: PeerView) -> str | None:
name = " ".join(part for part in (peer.first_name, peer.last_name) if part)
return name or peer.username
async def _versions(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> dict[tuple[int, int, datetime], asyncpg.Record]:
edited = [row for row in rows if row["kind"] == FeedKind.EDITED]
if not edited:
return {}
found = await pool.fetch(
_VERSIONS,
account_id,
[row["chat_id"] for row in edited],
[row["message_id"] for row in edited],
[row["at"] for row in edited],
)
return {
(row["chat_id"], row["message_id"], row["observed_at"]): row for row in found
}
async def _sender_names(
pool: asyncpg.Pool,
account_id: int,
messages: dict[tuple[int, int], MessageView],
chat_views: dict[int, ChatListItem],
) -> dict[int, str | None]:
ids = {m.sender_id for m in messages.values() if m.sender_id is not None}
found = await peers.get_peers(pool, account_id, list(ids))
names: dict[int, str | None] = {
chat_id: chat.title for chat_id, chat in chat_views.items()
}
names.update({peer.peer_id: _peer_name(peer) for peer in found})
return names
async def _build_items(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> list[FeedItem]:
if not rows:
return []
messages = await chats.get_messages_at(
pool,
account_id,
[(row["chat_id"], row["message_id"], row["message_date"]) for row in rows],
)
versions = await _versions(pool, account_id, rows)
chat_views = {
chat.chat_id: chat
for chat in await chats.get_chats(
pool, account_id, list({row["chat_id"] for row in rows})
)
}
names = await _sender_names(pool, account_id, messages, chat_views)
self_id = await self_user_id(pool, account_id)
items: list[FeedItem] = []
for row in rows:
message = messages.get((row["chat_id"], row["message_id"]))
chat = chat_views.get(row["chat_id"])
version = versions.get((row["chat_id"], row["message_id"], row["at"]))
sender_id = message.sender_id if message else None
items.append(
FeedItem(
id=row["id"],
kind=row["kind"],
at=row["at"],
since=row["since"],
message_count=row["message_count"],
chat=FeedChat(
chat_id=row["chat_id"],
title=chat.title if chat else None,
kind=row["chat_kind"],
has_avatar=chat.has_avatar if chat else False,
),
message_id=row["message_id"],
message=message,
sender_id=sender_id,
sender_name=names.get(sender_id) if sender_id is not None else None,
outgoing=self_id is not None and sender_id == self_id,
before=version["before"] if version else None,
after=version["text"] if version else None,
has_before=bool(version and version["has_before"]),
edit_date=version["edit_date"] if version else None,
)
)
return items
async def list_feed(
pool: asyncpg.Pool,
account_id: int,
feed: FeedFilter,
*,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT))
where, params = _where(account_id, feed, cursor)
params.append(size + 1)
rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
f"ORDER BY at DESC, id DESC LIMIT ${len(params)}",
*params,
)
page = rows[:size]
more = len(rows) > size
return FeedPage(
items=await _build_items(pool, account_id, page),
next_cursor=encode_cursor(page[-1]["at"], page[-1]["id"]) if more else None,
)
async def unseen(pool: asyncpg.Pool, account_id: int) -> FeedUnseen:
seen_at = await pool.fetchval(
"SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id
)
counts = await pool.fetchrow(_UNSEEN, account_id, seen_at)
latest = await list_feed(pool, account_id, FeedFilter(), limit=1)
return FeedUnseen(
count=counts["deleted"] + counts["edited"],
deleted=counts["deleted"],
edited=counts["edited"],
seen_at=seen_at,
latest=latest.items[0] if latest.items else None,
)
async def mark_seen(
pool: asyncpg.Pool, account_id: int, at: datetime | None
) -> FeedUnseen:
await pool.execute(_MARK_SEEN, account_id, at)
return await unseen(pool, account_id)
async def item_messages(
pool: asyncpg.Pool,
account_id: int,
item_id: int,
*,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> FeedMessagesPage | None:
row = await pool.fetchrow(
"SELECT chat_id, since, at FROM feed_items "
"WHERE id = $1 AND account_id = $2 AND kind = 'deleted'",
item_id,
account_id,
)
if row is None:
return None
size = max(1, min(limit, MAX_LIMIT))
views = await chats.get_deleted_between(
pool,
account_id,
row["chat_id"],
row["since"],
row["at"],
size + 1,
decode_cursor(cursor) if cursor else None,
)
page = views[:size]
more = len(views) > size
return FeedMessagesPage(
messages=page,
next_cursor=encode_cursor(page[-1].date, page[-1].message_id) if more else None,
)
+43
View File
@@ -212,6 +212,49 @@ class MessageVersionView(BaseModel):
text: str | None text: str | None
class FeedChat(BaseModel):
chat_id: int
title: str | None
kind: str
has_avatar: bool
class FeedItem(BaseModel):
id: int
kind: str
at: datetime
since: datetime
message_count: int
chat: FeedChat
message_id: int
message: MessageView | None
sender_id: int | None
sender_name: str | None
outgoing: bool
before: str | None = None
after: str | None = None
has_before: bool = False
edit_date: datetime | None = None
class FeedPage(BaseModel):
items: list[FeedItem]
next_cursor: str | None
class FeedUnseen(BaseModel):
count: int
deleted: int
edited: int
seen_at: datetime | None
latest: FeedItem | None
class FeedMessagesPage(BaseModel):
messages: list[MessageView]
next_cursor: str | None
class MediaView(BaseModel): class MediaView(BaseModel):
id: int id: int
account_id: int account_id: int
+38
View File
@@ -9,6 +9,11 @@ import type {
ChatLinkView, ChatLinkView,
DayCount, DayCount,
DiscoverItem, DiscoverItem,
FeedChatKind,
FeedKind,
FeedMessagesPage,
FeedPage,
FeedUnseen,
Folder, Folder,
JobStatus, JobStatus,
JobView, JobView,
@@ -274,6 +279,39 @@ export function listDeleted(
}); });
} }
export interface FeedQuery {
chat_id?: number;
chat_kind?: FeedChatKind;
cursor?: string;
kind?: FeedKind;
limit?: number;
}
export function listFeed(query: FeedQuery = {}): Promise<FeedPage> {
return request<FeedPage>("/feed", { account: true, query: { ...query } });
}
export function getFeedUnseen(): Promise<FeedUnseen> {
return request<FeedUnseen>("/feed/unseen", { account: true });
}
export function markFeedSeen(at?: string): Promise<FeedUnseen> {
return request<FeedUnseen>("/feed/seen", {
body: { account_id: accounts.selectedId, at },
method: "POST",
});
}
export function listFeedMessages(
itemId: number,
query: { cursor?: string; limit?: number } = {}
): Promise<FeedMessagesPage> {
return request<FeedMessagesPage>(`/feed/${itemId}/messages`, {
account: true,
query: { ...query },
});
}
export function getPeer(peerId: number): Promise<PeerView> { export function getPeer(peerId: number): Promise<PeerView> {
return request<PeerView>(`/peers/${peerId}`, { account: true }); return request<PeerView>(`/peers/${peerId}`, { account: true });
} }
+46
View File
@@ -222,6 +222,52 @@ export interface MessageVersion {
text: string | null; text: string | null;
} }
export type FeedKind = "all" | "deleted" | "edited";
export type FeedChatKind = "dm" | "group" | "channel";
export interface FeedChat {
chat_id: number;
has_avatar: boolean;
kind: FeedChatKind;
title: string | null;
}
export interface FeedItem {
after: string | null;
at: string;
before: string | null;
chat: FeedChat;
edit_date: string | null;
has_before: boolean;
id: number;
kind: Exclude<FeedKind, "all">;
message: MessageView | null;
message_count: number;
message_id: number;
outgoing: boolean;
sender_id: number | null;
sender_name: string | null;
since: string;
}
export interface FeedPage {
items: FeedItem[];
next_cursor: string | null;
}
export interface FeedUnseen {
count: number;
deleted: number;
edited: number;
latest: FeedItem | null;
seen_at: string | null;
}
export interface FeedMessagesPage {
messages: MessageView[];
next_cursor: string | null;
}
export interface MediaVersion { export interface MediaVersion {
file_size: number | null; file_size: number | null;
id: number; id: number;
+39 -14
View File
@@ -8,14 +8,17 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { formatPresence, isOnline } from "#lib/format/presence.js"; import { formatPresence, isOnline } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { withoutMessage } from "#lib/stores/feed.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
interface Props { interface Props {
chatId: number; chatId: number;
@@ -36,14 +39,20 @@
backfilling = true; backfilling = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true);
toasts.success("Догружаем новые сообщения"); toasts.success(t("chat.backfill.started"));
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("chat.backfill.failed"));
} finally { } finally {
backfilling = false; backfilling = false;
} }
} }
function back() {
const target = new URL(page.url.href);
target.pathname = "/app";
goto(withoutMessage(target));
}
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
@@ -82,7 +91,9 @@
const fallbackTitle = $derived( const fallbackTitle = $derived(
chat?.title ?? chat?.title ??
discovered?.title ?? discovered?.title ??
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`) (isDm
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chatId }))
); );
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle); const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => { const subtitle = $derived.by(() => {
@@ -97,9 +108,11 @@
} }
const count = chat?.message_count ?? discovered?.message_count ?? 0; const count = chat?.message_count ?? discovered?.message_count ?? 0;
if (count > 0) { if (count > 0) {
return `${count} messages`; return t("chat.messages", { count });
} }
return discovered?.kind === "channel" ? "channel" : "group"; return discovered?.kind === "channel"
? t("chat.kind.channel")
: t("chat.kind.group");
}); });
const avatarKind = $derived(isDm ? "peer" : "chat"); const avatarKind = $derived(isDm ? "peer" : "chat");
const hasAvatar = $derived( const hasAvatar = $derived(
@@ -114,8 +127,8 @@
<header class="chat-header"> <header class="chat-header">
<div class="back"> <div class="back">
<Button <Button
aria-label="Назад" aria-label={t("common.back")}
onclick={() => goto("/app")} onclick={back}
round round
smaller smaller
variant="translucent" variant="translucent"
@@ -129,7 +142,7 @@
})} })}
<button <button
{...props} {...props}
aria-label="Открыть профиль" aria-label={t("chat.openProfile")}
class="peek" class="peek"
onclick={() => ui.openPanel("profile")} onclick={() => ui.openPanel("profile")}
type="button" type="button"
@@ -157,23 +170,26 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")} <ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
>Открыть профиль</ContextMenuItem >{t("chat.openProfile")}</ContextMenuItem
>
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
>{t("feed.changes")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="play-story" icon="play-story"
onselect={() => ui.openPanel("stories")} onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem >{t("settings.stories")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => policyUi.edit({ chatId }, title)} onselect={() => policyUi.edit({ chatId }, title)}
>Политика захвата</ContextMenuItem >{t("settings.policy")}</ContextMenuItem
> >
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<div class="actions"> <div class="actions">
<Button <Button
aria-label="Поиск в чате" aria-label={t("chat.search")}
onclick={() => ui.openPanel("search")} onclick={() => ui.openPanel("search")}
round round
smaller smaller
@@ -182,7 +198,16 @@
<Icon name="search" /> <Icon name="search" />
</Button> </Button>
<Button <Button
aria-label="Аналитика" aria-label={t("feed.changes")}
onclick={() => ui.openPanel("changes")}
round
smaller
variant="translucent"
>
<Icon name="recent" />
</Button>
<Button
aria-label={t("chat.analytics")}
onclick={() => ui.openPanel("presence")} onclick={() => ui.openPanel("presence")}
round round
smaller smaller
@@ -191,7 +216,7 @@
<Icon name="stats" /> <Icon name="stats" />
</Button> </Button>
<Button <Button
aria-label="Догрузить новые сообщения" aria-label={t("chat.backfill")}
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
round round
+11 -4
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import ChatListItem from "#lib/components/ChatListItem.svelte"; import ChatListItem from "#lib/components/ChatListItem.svelte";
import FeedRow from "#lib/components/feed/FeedRow.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Skeleton from "#lib/components/ui/Skeleton.svelte"; import Skeleton from "#lib/components/ui/Skeleton.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 { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
@@ -46,7 +48,7 @@
return; return;
} }
untrack(() => folders.load()).catch(() => untrack(() => folders.load()).catch(() =>
toasts.error("Failed to load folders") toasts.error(t("chats.foldersFailed"))
); );
}); });
@@ -60,7 +62,7 @@
scrollTop = 0; scrollTop = 0;
} }
untrack(() => chats.load(folderId)).catch(() => untrack(() => chats.load(folderId)).catch(() =>
toasts.error("Failed to load chats") toasts.error(t("chats.loadFailed"))
); );
}); });
@@ -99,6 +101,9 @@
bind:this={viewport} bind:this={viewport}
bind:clientHeight={viewportHeight} bind:clientHeight={viewportHeight}
> >
{#if folders.selectedId === null}
<FeedRow />
{/if}
{#if chats.loading && list.length === 0} {#if chats.loading && list.length === 0}
{#each skeletonRows as index (index)} {#each skeletonRows as index (index)}
<div class="row-skeleton"> <div class="row-skeleton">
@@ -113,8 +118,10 @@
<EmptyState <EmptyState
description={folders.selectedId === null description={folders.selectedId === null
? undefined ? undefined
: "No chats match this folder yet"} : t("chats.emptyFolderHint")}
title={folders.selectedId === null ? "No chats yet" : "Empty folder"} title={folders.selectedId === null
? t("chats.empty")
: t("chats.emptyFolder")}
/> />
{:else} {:else}
<div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px"> <div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px">
@@ -5,6 +5,7 @@
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 { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.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";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
@@ -26,7 +27,9 @@
const title = $derived( const title = $derived(
chat.title ?? chat.title ??
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`) (chat.chat_id > 0
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: 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);
@@ -45,7 +48,7 @@
return ""; return "";
} }
if (chat.last_sender_id === ownId) { if (chat.last_sender_id === ownId) {
return "You: "; return `${t("chat.you")}: `;
} }
const peer = peers.get(chat.last_sender_id as number); const peer = peers.get(chat.last_sender_id as number);
if (!peer) { if (!peer) {
@@ -55,7 +58,7 @@
}); });
const preview = $derived( const preview = $derived(
chat.last_text ?? (chat.message_count > 0 ? "Media" : "") chat.last_text ?? (chat.message_count > 0 ? t("chat.media") : "")
); );
</script> </script>
@@ -95,24 +98,27 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onclick} <ContextMenuItem icon="open-in-new-tab" onselect={onclick}
>Открыть</ContextMenuItem >{t("chat.open")}</ContextMenuItem
> >
<ContextMenuItem icon="info" onselect={() => openWith("profile")} <ContextMenuItem icon="info" onselect={() => openWith("profile")}
>Профиль</ContextMenuItem >{t("chat.profile")}</ContextMenuItem
> >
<ContextMenuItem icon="search" onselect={() => openWith("search")} <ContextMenuItem icon="search" onselect={() => openWith("search")}
>Поиск в чате</ContextMenuItem >{t("chat.search")}</ContextMenuItem
>
<ContextMenuItem icon="recent" onselect={() => openWith("changes")}
>{t("feed.changes")}</ContextMenuItem
> >
<ContextMenuItem icon="stats" onselect={() => openWith("presence")} <ContextMenuItem icon="stats" onselect={() => openWith("presence")}
>Аналитика</ContextMenuItem >{t("chat.analytics")}</ContextMenuItem
> >
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")} <ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
>Сторис</ContextMenuItem >{t("settings.stories")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)} onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
>Политика захвата</ContextMenuItem >{t("settings.policy")}</ContextMenuItem
> >
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
+129 -34
View File
@@ -12,6 +12,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 { formatDay } from "#lib/format/datetime.js"; import { formatDay } from "#lib/format/datetime.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 { 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";
@@ -117,16 +118,81 @@
peers.ensure(ids); peers.ensure(ids);
} }
function jumpToMessage(messageId: number) { function memberIds(message: MessageView): number[] {
return [message.message_id, ...message.media.map((ref) => ref.message_id)];
}
function holds(message: MessageView, messageId: number): boolean {
return memberIds(message).includes(messageId);
}
function sameAlbum(a: MessageView, b: MessageView): boolean {
return (
a.media_group_id !== null &&
a.media_group_id === b.media_group_id &&
a.chat_id === b.chat_id
);
}
function albumPrimary(base: MessageView, incoming: MessageView) {
if (
base.message_id === incoming.message_id ||
(incoming.text && !base.text)
) {
return incoming;
}
if (base.text) {
return base;
}
return incoming.message_id < base.message_id ? incoming : base;
}
function joinAlbum(base: MessageView, incoming: MessageView): MessageView {
const media = new Map(base.media.map((ref) => [ref.message_id, ref]));
for (const ref of incoming.media) {
media.set(ref.message_id, ref);
}
return {
...albumPrimary(base, incoming),
media: [...media.values()].sort((a, b) => a.message_id - b.message_id),
};
}
function mergeAlbums(list: MessageView[]): MessageView[] {
const out: MessageView[] = [];
for (const message of list) {
const prev = out.at(-1);
if (prev && sameAlbum(prev, message)) {
out[out.length - 1] = joinAlbum(prev, message);
} else {
out.push(message);
}
}
return out;
}
function knownIds(): Set<number> {
return new Set(messages.flatMap(memberIds));
}
function ownerOf(messageId: number): number | null {
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
}
function jumpToMessage(messageId: number, smooth = true) {
const owner = ownerOf(messageId) ?? messageId;
const target = container?.querySelector<HTMLElement>( const target = container?.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]` `[data-message-id="${owner}"]`
); );
if (!target) { if (!target) {
toasts.error("Message not loaded"); toasts.error(t("thread.notFound"));
return; return;
} }
target.scrollIntoView({ behavior: "smooth", block: "center" }); target.scrollIntoView({
highlightId = messageId; behavior: smooth ? "smooth" : "auto",
block: "center",
});
highlightId = owner;
if (highlightTimer) { if (highlightTimer) {
clearTimeout(highlightTimer); clearTimeout(highlightTimer);
} }
@@ -160,20 +226,28 @@
loading = true; loading = true;
hasMore = true; hasMore = true;
hasNewer = false; hasNewer = false;
const pending = ui.jumpTarget?.chatId === chatId ? ui.jumpTarget : null;
try { try {
if (pending) {
ui.clearJump();
await loadAround(pending.messageId);
await tick();
jumpToMessage(pending.messageId, false);
return;
}
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
include_deleted: true, include_deleted: true,
limit: PAGE, limit: PAGE,
offset: 0, offset: 0,
}); });
messages = [...page].reverse(); messages = mergeAlbums([...page].reverse());
hasMore = page.length > 0; hasMore = page.length > 0;
ensurePeers(messages); ensurePeers(messages);
await tick(); await tick();
scrollToBottom(); scrollToBottom();
updateStuck(); updateStuck();
} catch { } catch {
toasts.error("Failed to load messages"); toasts.error(t("thread.loadFailed"));
} finally { } finally {
loading = false; loading = false;
} }
@@ -215,16 +289,18 @@
const prevTop = el.scrollTop; const prevTop = el.scrollTop;
try { try {
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
before_id: messages[0].message_id, before_id: Math.min(...memberIds(messages[0])),
include_deleted: true, include_deleted: true,
limit: PAGE, limit: PAGE,
}); });
const known = new Set(messages.map((m) => m.message_id)); const known = knownIds();
const fresh = page.filter((m) => !known.has(m.message_id)); const fresh = page.filter((m) =>
memberIds(m).some((id) => !known.has(id))
);
hasMore = fresh.length > 0; hasMore = fresh.length > 0;
if (fresh.length > 0) { if (fresh.length > 0) {
suppressAppear = true; suppressAppear = true;
messages = [...[...fresh].reverse(), ...messages]; messages = mergeAlbums([...[...fresh].reverse(), ...messages]);
ensurePeers(fresh); ensurePeers(fresh);
await tick(); await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight); el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
@@ -242,17 +318,20 @@
} }
loadingNewer = true; loadingNewer = true;
try { try {
const last = messages.at(-1);
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
after_id: messages.at(-1)?.message_id, after_id: last ? Math.max(...memberIds(last)) : undefined,
include_deleted: true, include_deleted: true,
limit: PAGE, limit: PAGE,
}); });
const known = new Set(messages.map((m) => m.message_id)); const known = knownIds();
const fresh = page.filter((m) => !known.has(m.message_id)); const fresh = page.filter((m) =>
memberIds(m).some((id) => !known.has(id))
);
hasNewer = fresh.length > 0; hasNewer = fresh.length > 0;
if (fresh.length > 0) { if (fresh.length > 0) {
suppressAppear = true; suppressAppear = true;
messages = [...messages, ...fresh]; messages = mergeAlbums([...messages, ...fresh]);
ensurePeers(fresh); ensurePeers(fresh);
await tick(); await tick();
await trimHead(); await trimHead();
@@ -278,7 +357,7 @@
limit: PAGE, limit: PAGE,
}), }),
]); ]);
messages = [...[...older].reverse(), ...newer]; messages = mergeAlbums([...[...older].reverse(), ...newer]);
hasMore = older.length > 0; hasMore = older.length > 0;
hasNewer = newer.length > 0; hasNewer = newer.length > 0;
ensurePeers(messages); ensurePeers(messages);
@@ -331,13 +410,20 @@
} }
function replaceMessage(message: MessageView) { function replaceMessage(message: MessageView) {
messages = messages.map((m) => messages = messages.map((m) => {
m.message_id === message.message_id ? message : m if (sameAlbum(m, message)) {
); return joinAlbum(m, message);
}
return m.message_id === message.message_id ? message : m;
});
} }
async function appendMessage(message: MessageView) { async function appendMessage(message: MessageView) {
if (messages.some((m) => m.message_id === message.message_id)) { const last = messages.at(-1);
if (
messages.some((m) => holds(m, message.message_id)) ||
(last && sameAlbum(last, message))
) {
replaceMessage(message); replaceMessage(message);
return; return;
} }
@@ -357,7 +443,7 @@
function applyDelete(ids: number[]) { function applyDelete(ids: number[]) {
const now = new Date().toISOString(); const now = new Date().toISOString();
messages = messages.map((m) => messages = messages.map((m) =>
ids.includes(m.message_id) && m.deleted_at === null m.deleted_at === null && ids.some((id) => holds(m, id))
? { ...m, deleted_at: now } ? { ...m, deleted_at: now }
: m : m
); );
@@ -403,20 +489,24 @@
}); });
const fresh = [...page].reverse(); const fresh = [...page].reverse();
const known = new Map(messages.map((m) => [m.message_id, m])); const known = new Map(messages.map((m) => [m.message_id, m]));
const maxId = messages.reduce((acc, m) => Math.max(acc, m.message_id), 0); const maxId = Math.max(0, ...messages.flatMap(memberIds));
const stick = isNearBottom(); const stick = isNearBottom();
const appended: MessageView[] = []; const appended: MessageView[] = [];
for (const message of fresh) { for (const message of fresh) {
if (known.has(message.message_id)) { const current = known.get(message.message_id);
known.set(message.message_id, message); if (current) {
known.set(
message.message_id,
sameAlbum(current, message) ? joinAlbum(current, message) : message
);
} else if (message.message_id > maxId) { } else if (message.message_id > maxId) {
appended.push(message); appended.push(message);
} }
} }
messages = [ messages = mergeAlbums([
...messages.map((m) => known.get(m.message_id) ?? m), ...messages.map((m) => known.get(m.message_id) ?? m),
...appended, ...appended,
]; ]);
ensurePeers(fresh); ensurePeers(fresh);
await trimHead(); await trimHead();
if (appended.length > 0 && stick) { if (appended.length > 0 && stick) {
@@ -425,12 +515,17 @@
} }
} }
async function jumpToTarget(messageId: number) { async function goTo(messageId: number) {
if (!messages.some((m) => m.message_id === messageId)) { try {
const loaded = ownerOf(messageId) !== null;
if (!loaded) {
await loadAround(messageId); await loadAround(messageId);
} }
await tick(); await tick();
jumpToMessage(messageId); jumpToMessage(messageId, loaded);
} catch {
toasts.error(t("thread.loadFailed"));
}
} }
$effect(() => { $effect(() => {
@@ -439,7 +534,7 @@
return; return;
} }
ui.clearJump(); ui.clearJump();
jumpToTarget(target.messageId); goTo(target.messageId);
}); });
$effect(() => { $effect(() => {
@@ -460,7 +555,7 @@
</script> </script>
<div class="message-pane"> <div class="message-pane">
<PinnedBar {chatId} onjump={jumpToMessage} /> <PinnedBar {chatId} onjump={goTo} />
<div <div
class="message-list custom-scroll" class="message-list custom-scroll"
onscroll={onScroll} onscroll={onScroll}
@@ -490,7 +585,7 @@
</div> </div>
{/if} {/if}
{#if row.message.service} {#if row.message.service}
<ActionMessage message={row.message} onjump={jumpToMessage} /> <ActionMessage message={row.message} onjump={goTo} />
{:else} {:else}
<MessageBubble <MessageBubble
animate={!suppressAppear} animate={!suppressAppear}
@@ -499,7 +594,7 @@
{isGroupChat} {isGroupChat}
lastInGroup={row.lastInGroup} lastInGroup={row.lastInGroup}
message={row.message} message={row.message}
onjump={jumpToMessage} onjump={goTo}
onmedia={(index) => openMedia(row.message, index)} onmedia={(index) => openMedia(row.message, index)}
onversions={() => openVersions(row.message.message_id)} onversions={() => openVersions(row.message.message_id)}
own={row.own} own={row.own}
@@ -514,7 +609,7 @@
</div> </div>
{#if hasNewer} {#if hasNewer}
<button <button
aria-label="К последним сообщениям" aria-label={t("thread.toLatest")}
class="to-latest" class="to-latest"
onclick={goToLatest} onclick={goToLatest}
type="button" type="button"
+33 -26
View File
@@ -4,6 +4,7 @@
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";
import ChatChanges from "#lib/components/feed/ChatChanges.svelte";
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte"; import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte"; import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte"; import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
@@ -21,35 +22,37 @@
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte"; import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
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 { 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 { 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";
const titles: Record<RightPanel, string> = $derived({ const titles: Record<RightPanel, MessageKey> = {
alerts: t("settings.alerts"), alerts: "settings.alerts",
annotations: "Заметки", annotations: "panel.annotations",
bots: "Боты", bots: "settings.bots",
callbacks: "Callback-данные", callbacks: "panel.callbacks",
jobs: "Бэкфилл и задачи", changes: "feed.changes",
links: "Ссылки", jobs: "settings.jobs",
policy: "Политика захвата", links: "panel.links",
presence: "Аналитика", policy: "settings.policy",
profile: "Профиль", presence: "chat.analytics",
reactions: "Реакции", profile: "chat.profile",
readers: t("read.readers.title"), reactions: "panel.reactions",
search: "Поиск", readers: "read.readers.title",
shares: "Файлы по ссылке", search: "panel.search",
storage: "Хранилище", shares: "settings.shares",
"storage-chat": "Файлы чата", storage: "settings.storage",
"storage-trash": "Корзина", "storage-chat": "panel.chatFiles",
stories: "Сторис", "storage-trash": "panel.trash",
"stories-all": "Все сторис", stories: "settings.stories",
versions: "Версии", "stories-all": "panel.allStories",
watches: t("settings.watches"), versions: "panel.versions",
webhooks: t("watches.webhooks"), watches: "settings.watches",
}); webhooks: "watches.webhooks",
};
const parents: Partial<Record<RightPanel, RightPanel>> = { const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage", "storage-chat": "storage",
@@ -66,14 +69,14 @@
ui.rightPanel === "storage-chat" && storageUi.chatId !== null ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId) ? chats.byId(storageUi.chatId)
: undefined; : undefined;
return chat?.title ?? titles[ui.rightPanel]; return chat?.title ?? t(titles[ui.rightPanel]);
}); });
</script> </script>
<header class="right-header"> <header class="right-header">
{#if parent} {#if parent}
<Button <Button
aria-label="Назад" aria-label={t("common.back")}
onclick={() => ui.openPanel(parent)} onclick={() => ui.openPanel(parent)}
round round
smaller smaller
@@ -83,7 +86,7 @@
</Button> </Button>
{:else} {:else}
<Button <Button
aria-label="Закрыть" aria-label={t("common.close")}
onclick={() => ui.closePanel()} onclick={() => ui.closePanel()}
round round
smaller smaller
@@ -98,6 +101,10 @@
<div class="right-body custom-scroll"> <div class="right-body custom-scroll">
{#if ui.rightPanel === "profile"} {#if ui.rightPanel === "profile"}
<ProfilePanel /> <ProfilePanel />
{:else if ui.rightPanel === "changes"}
{#key accounts.selectedId}
<ChatChanges />
{/key}
{:else if ui.rightPanel === "policy"} {:else if ui.rightPanel === "policy"}
{#key `${accounts.selectedId}:${policyUi.revision}`} {#key `${accounts.selectedId}:${policyUi.revision}`}
<PolicyEditor /> <PolicyEditor />
@@ -0,0 +1,35 @@
<script lang="ts">
import type { FeedKind } from "#lib/api/types.js";
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
import FeedList from "#lib/components/feed/FeedList.svelte";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { page } from "$app/state";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
let kind = $state<FeedKind>("all");
function pick(next: FeedKind) {
kind = next;
}
function open(messageId: number) {
if (chatId === null) {
return;
}
ui.requestJump(chatId, messageId);
if (isMobile()) {
ui.closePanel();
}
}
</script>
{#if chatId !== null}
<FeedFilters {kind} onkind={pick} />
{#key chatId}
<FeedList {chatId} {kind} onopen={(_, messageId) => open(messageId)} />
{/key}
{/if}
@@ -0,0 +1,151 @@
<script lang="ts">
import { onMount } from "svelte";
import { ripple } from "#lib/actions/ripple.js";
import { listFeedMessages } from "#lib/api/endpoints.js";
import type { FeedItem, MessageView } from "#lib/api/types.js";
import FeedText from "#lib/components/feed/FeedText.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDayTime, formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
item: FeedItem;
onopen: (messageId: number) => void;
senderOf: (message: MessageView) => string;
}
let { item, onopen, senderOf }: Props = $props();
const PAGE = 50;
let messages = $state<MessageView[]>([]);
let cursor = $state<string | null>(null);
let loading = $state(false);
let failed = $state(false);
async function load(next: string | null) {
loading = true;
failed = false;
try {
const page = await listFeedMessages(item.id, {
cursor: next ?? undefined,
limit: PAGE,
});
messages = [...messages, ...page.messages];
cursor = page.next_cursor;
} catch {
failed = true;
} finally {
loading = false;
}
}
onMount(() => {
load(null);
});
</script>
<div class="bulk">
{#each messages as message (message.message_id)}
<button
class="member"
onclick={() => onopen(message.message_id)}
type="button"
use:ripple
>
<FeedText deleted {message} prefix={senderOf(message)} />
<time
class="date"
datetime={message.date}
title={formatFull(message.date)}
>
{formatDayTime(message.date)}
</time>
</button>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<button class="more" onclick={() => load(cursor)} type="button">
{t("feed.retry")}
</button>
{:else if cursor !== null}
<button class="more" onclick={() => load(cursor)} type="button">
{t("feed.more")}
</button>
{/if}
</div>
<style lang="scss">
.bulk {
display: flex;
flex-direction: column;
gap: 0.125rem;
margin: 0 0 0.375rem 4.125rem;
padding-inline-start: 0.625rem;
border-inline-start: 2px solid var(--color-borders);
}
.member {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
gap: 0.5rem;
align-items: baseline;
width: 100%;
padding: 0.375rem 0.5rem;
border: 0;
border-radius: var(--border-radius-default-small);
font: inherit;
text-align: start;
color: var(--color-text);
background: none;
--ripple-color: var(--color-interactive-element-hover);
:global(.feed-text) {
flex: 1;
min-width: 0;
font-size: 0.875rem;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.state {
display: flex;
justify-content: center;
padding: 0.5rem 0;
}
.more {
cursor: pointer;
margin-top: 0.25rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
}
</style>
@@ -0,0 +1,363 @@
<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import type { FeedItem, MessageView } from "#lib/api/types.js";
import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
import FeedText from "#lib/components/feed/FeedText.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { wordDiff } from "#lib/format/diff.js";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
interface Props {
inChat?: boolean;
item: FeedItem;
onopen: (messageId: number) => void;
}
let { item, onopen, inChat = false }: Props = $props();
let expanded = $state(false);
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const bulk = $derived(item.kind === "deleted" && item.message_count > 1);
const isGroup = $derived(item.chat.kind === "group");
$effect(() => {
if (item.sender_id !== null && item.sender_id > 0) {
peers.ensure([item.sender_id]);
}
});
function senderName(senderId: number | null, fallback: string | null) {
if (senderId === null) {
return fallback ?? "";
}
if (senderId === ownId) {
return t("chat.you");
}
const peer = peers.get(senderId);
if (peer) {
return peerName(peer);
}
return fallback ?? String(senderId);
}
function shortName(senderId: number | null, fallback: string | null) {
if (senderId === ownId) {
return t("chat.you");
}
const peer = senderId === null ? undefined : peers.get(senderId);
return peer?.first_name || senderName(senderId, fallback);
}
function prefixOf(message: MessageView): string {
if (message.sender_id === ownId) {
return t("chat.you");
}
return isGroup ? shortName(message.sender_id, null) : "";
}
const chatTitle = $derived(
item.chat.title ??
(item.chat.chat_id > 0
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
);
const title = $derived.by(() => {
if (!inChat) {
return chatTitle;
}
if (bulk) {
return t("feed.bulk", { count: item.message_count });
}
return senderName(item.sender_id, item.sender_name);
});
const avatar = $derived.by(() => {
if (!inChat) {
return {
has: item.chat.has_avatar,
id: item.chat.chat_id,
kind: item.chat.chat_id > 0 ? ("peer" as const) : ("chat" as const),
};
}
const id = item.sender_id ?? item.chat.chat_id;
return {
has: id > 0 ? (peers.get(id)?.has_avatar ?? false) : item.chat.has_avatar,
id,
kind: id > 0 ? ("peer" as const) : ("chat" as const),
};
});
const prefix = $derived.by(() => {
if (inChat) {
return "";
}
if (item.outgoing) {
return t("chat.you");
}
return isGroup ? shortName(item.sender_id, item.sender_name) : "";
});
const sameText = $derived(item.has_before && item.before === item.after);
const diff = $derived(
item.kind === "edited" && item.has_before && !sameText
? wordDiff(item.before ?? "", item.after ?? "")
: null
);
function open() {
if (bulk) {
expanded = !expanded;
return;
}
onopen(item.message_id);
}
</script>
<div class="feed-entry">
<button
aria-expanded={bulk ? expanded : undefined}
class="row"
onclick={open}
type="button"
use:ripple
>
{#if inChat && bulk}
<span class="glyph"><Icon name="delete" size="1.5rem" /></span>
{:else}
<Avatar
avatar={{ id: avatar.id, kind: avatar.kind }}
colorKey={avatar.id}
hasAvatar={avatar.has}
name={title}
size={3}
/>
{/if}
<span class="body">
<span class="top">
<span class="title">{title}</span>
<time datetime={item.at} title={formatFull(item.at)}>
{formatListDate(item.at)}
</time>
</span>
{#if bulk}
<span class="mark">
<Icon name="delete" size="0.875rem" />
{inChat
? t("feed.deleted")
: t("feed.bulk", { count: item.message_count })}
<span class="chevron" class:open={expanded}>
<Icon name="down" size="1rem" />
</span>
</span>
{#if !expanded}
<FeedText deleted message={item.message} {prefix} />
{/if}
{:else if item.kind === "deleted"}
<FeedText
deleted
mark={t("feed.deleted")}
message={item.message}
{prefix}
/>
{:else if sameText}
<span class="mark edited">
<Icon name="edit" size="0.875rem" />
{t("feed.mediaChanged")}
</span>
<FeedText message={item.message} {prefix} />
{:else}
<span class="change">
<span class="tag">{t("feed.before")}</span>
{#if !item.has_before}
<span class="missing">{t("feed.lostText")}</span>
{:else if !item.before}
<span class="missing">{t("feed.noText")}</span>
{:else if diff}
{#each diff.before as part, index (index)}
<span class:removed={part.changed}>{part.text}</span>
{/each}
{:else}
{item.before}
{/if}
</span>
<span class="change now">
<span class="tag">{t("feed.after")}</span>
{#if prefix}
<span class="prefix">{prefix}:</span>
{/if}
{#if !item.after}
<span class="missing">{t("feed.noText")}</span>
{:else if diff}
{#each diff.after as part, index (index)}
<span class:added={part.changed}>{part.text}</span>
{/each}
{:else}
{item.after}
{/if}
</span>
{/if}
</span>
</button>
{#if bulk && expanded}
{#key `${item.id}:${item.message_count}`}
<FeedBulk {item} {onopen} senderOf={prefixOf} />
{/key}
{/if}
</div>
<style lang="scss">
.row {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
gap: 0.625rem;
align-items: flex-start;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: var(--border-radius-default-small);
font: inherit;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-gray);
}
.body {
display: flex;
overflow: hidden;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.top {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.title {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
time {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.mark {
display: inline-flex;
gap: 0.25rem;
align-items: center;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
&.edited {
color: var(--color-text-secondary);
}
}
.chevron {
display: inline-flex;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
&.open {
transform: rotate(180deg);
}
}
.change {
overflow: hidden;
display: -webkit-box;
font-size: 0.9375rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
overflow-wrap: anywhere;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
&.now {
color: var(--color-text);
}
}
.tag {
margin-inline-end: 0.375rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.prefix {
margin-inline-end: 0.25rem;
color: var(--color-text-secondary);
}
.removed {
text-decoration: line-through;
text-decoration-color: var(--color-error);
}
.added {
border-radius: 0.25rem;
background-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
box-decoration-break: clone;
}
.missing {
font-style: italic;
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import type { FeedChatKind, FeedKind } from "#lib/api/types.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js";
interface Props {
chats?: FeedChatKind | null;
kind: FeedKind;
onchats?: (chats: FeedChatKind | null) => void;
onkind: (kind: FeedKind) => void;
}
let { kind, chats = null, onkind, onchats }: Props = $props();
const KIND_KEYS = {
all: "feed.filter.all",
deleted: "feed.filter.deleted",
edited: "feed.filter.edited",
} as const;
const CHAT_KEYS = {
channel: "feed.chats.channel",
dm: "feed.chats.dm",
group: "feed.chats.group",
} as const;
</script>
<div class="filters">
<div class="group">
{#each FEED_KINDS as option (option)}
<button
aria-pressed={kind === option}
class="chip"
onclick={() => onkind(option)}
type="button"
class:on={kind === option}
>
{t(KIND_KEYS[option])}
</button>
{/each}
</div>
{#if onchats}
<div class="group">
{#each FEED_CHAT_KINDS as option (option)}
<button
aria-pressed={chats === option}
class="chip"
onclick={() => onchats(chats === option ? null : option)}
type="button"
class:on={chats === option}
>
{t(CHAT_KEYS[option])}
</button>
{/each}
</div>
{/if}
</div>
<style lang="scss">
.filters {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.375rem 0.75rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--color-borders);
}
.group {
display: inline-flex;
flex-wrap: nowrap;
gap: 0.25rem;
}
.chip {
cursor: pointer;
display: inline-flex;
align-items: center;
height: 1.75rem;
padding: 0 0.625rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text);
white-space: nowrap;
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&.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;
}
@media (hover: hover) {
&:not(.on):hover {
background-color: var(--color-background-secondary-accent);
}
}
}
</style>
@@ -0,0 +1,191 @@
<script lang="ts">
import { untrack } from "svelte";
import { visible } from "#lib/actions/visible.js";
import { type FeedQuery, listFeed } from "#lib/api/endpoints.js";
import type { FeedChatKind, FeedItem, FeedKind } from "#lib/api/types.js";
import FeedEntry from "#lib/components/feed/FeedEntry.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 { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { feed } from "#lib/stores/feed.svelte.js";
interface Props {
chatId?: number;
chats?: FeedChatKind | null;
kind: FeedKind;
onopen: (item: FeedItem, messageId: number) => void;
}
let { kind, chats = null, chatId, onopen }: Props = $props();
const PAGE = 30;
const EMPTY_KEYS = {
all: "feed.empty.all",
deleted: "feed.empty.deleted",
edited: "feed.empty.edited",
} as const;
let items = $state<FeedItem[]>([]);
let cursor = $state<string | null>(null);
let loading = $state(true);
let failed = $state(false);
let token = 0;
let { revision } = feed;
const filter = $derived<FeedQuery>({
chat_id: chatId,
chat_kind: chats ?? undefined,
kind,
limit: PAGE,
});
let active: FeedQuery = {};
function byTime(a: FeedItem, b: FeedItem): number {
return Date.parse(b.at) - Date.parse(a.at) || b.id - a.id;
}
async function reload() {
token += 1;
const mine = token;
loading = true;
failed = false;
try {
const page = await listFeed(active);
if (mine === token) {
({ items } = page);
cursor = page.next_cursor;
}
} catch {
if (mine === token) {
failed = true;
}
} finally {
if (mine === token) {
loading = false;
}
}
}
async function more() {
if (loading || cursor === null) {
return;
}
const mine = token;
loading = true;
failed = false;
try {
const page = await listFeed({ ...active, cursor });
if (mine === token) {
const known = new Set(items.map((item) => item.id));
items = [...items, ...page.items.filter((item) => !known.has(item.id))];
cursor = page.next_cursor;
}
} catch {
if (mine === token) {
failed = true;
}
} finally {
if (mine === token) {
loading = false;
}
}
}
async function refreshHead() {
const mine = token;
const page = await listFeed(active);
if (mine !== token) {
return;
}
const fresh = new Set(page.items.map((item) => item.id));
const rest = items.filter((item) => !fresh.has(item.id));
items = [...page.items, ...rest].sort(byTime);
if (items.length === page.items.length) {
cursor = page.next_cursor;
}
}
$effect(() => {
const next = filter;
if (accounts.selectedId === null) {
return;
}
active = next;
untrack(() => reload());
});
$effect(() => {
const next = feed.revision;
if (next === revision) {
return;
}
revision = next;
untrack(() => refreshHead()).catch(() => undefined);
});
</script>
<div class="feed-list">
{#if items.length === 0 && loading}
<div class="state"><Spinner /></div>
{:else if items.length === 0 && failed}
<div class="state">
<p>{t("feed.error")}</p>
<Button onclick={reload} variant="text">{t("feed.retry")}</Button>
</div>
{:else if items.length === 0}
<EmptyState title={t(EMPTY_KEYS[kind])} />
{:else}
{#each items as item (item.id)}
<FeedEntry
inChat={chatId !== undefined}
{item}
onopen={(messageId) => onopen(item, messageId)}
/>
{/each}
{#if cursor !== null}
{#if failed}
<div class="state">
<Button onclick={more} variant="text">{t("feed.retry")}</Button>
</div>
{:else}
{#key cursor}
<div class="state" use:visible={more}>
{#if loading}
<Spinner />
{/if}
</div>
{/key}
{/if}
{/if}
{/if}
</div>
<style lang="scss">
.feed-list {
display: flex;
flex-direction: column;
min-height: 100%;
padding: 0.25rem 0.4375rem 0.5rem;
}
.state {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
justify-content: center;
min-height: 3rem;
padding: 1rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
font-size: 0.875rem;
}
}
</style>
@@ -0,0 +1,216 @@
<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import type { FeedItem } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { mediaKindLabel } from "#lib/format/media.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { feed } from "#lib/stores/feed.svelte.js";
import { page } from "$app/state";
const BADGE_LIMIT = 10_000;
const latest = $derived(feed.unseen?.latest ?? null);
const count = $derived(feed.unseen?.count ?? 0);
const badge = $derived(
count < BADGE_LIMIT
? String(count)
: new Intl.NumberFormat(locale.tag, { notation: "compact" }).format(count)
);
$effect(() => {
if (accounts.selectedId !== null) {
feed.sync();
}
});
function chatTitle(item: FeedItem): string {
return (
item.chat.title ??
(item.chat.chat_id > 0
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
);
}
function summary(item: FeedItem): string {
if (item.kind === "deleted" && item.message_count > 1) {
return t("feed.bulk", { count: item.message_count });
}
const text = item.kind === "edited" ? item.after : item.message?.text;
const media = item.message?.media[0]?.kind ?? null;
return text?.trim() || mediaKindLabel(media) || t("feed.noText");
}
</script>
<button
class="feed-row"
onclick={() => feed.open(page.url)}
type="button"
use:ripple
>
<span class="avatar"><Icon name="recent" /></span>
<span class="info">
<span class="info-row">
<span class="title">{t("feed.title")}</span>
{#if latest}
<span class="date">{formatListDate(latest.at)}</span>
{/if}
</span>
<span class="subtitle">
<span class="preview">
{#if latest}
<Icon
class="kind"
name={latest.kind === "deleted" ? "delete" : "edit"}
size="0.875rem"
/>
<span class="chat">{chatTitle(latest)}:</span>
<span
class:struck={latest.kind === "deleted" &&
latest.message_count === 1}
>{summary(latest)}</span
>
{:else}
{t("feed.empty.all")}
{/if}
</span>
{#if count > 0}
<span class="badge">{badge}</span>
{/if}
</span>
</span>
</button>
<style lang="scss">
.feed-row {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
gap: 0.625rem;
align-items: center;
width: 100%;
height: 4.5rem;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.avatar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3.375rem;
height: 3.375rem;
border-radius: 50%;
font-size: 1.75rem;
color: var(--color-white);
background-color: var(--color-gray);
}
.info {
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.info-row {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.title {
overflow: hidden;
flex: 1;
font-size: 1rem;
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);
}
.subtitle {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.125rem;
}
.preview {
overflow: hidden;
flex: 1;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
:global(.kind) {
margin-inline-end: 0.125rem;
vertical-align: -0.125rem;
}
}
.chat {
color: var(--color-text);
}
.struck {
text-decoration: line-through;
}
.badge {
flex-shrink: 0;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.4375rem;
border-radius: 0.75rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
line-height: 1.5rem;
color: var(--color-white);
text-align: center;
background-color: var(--color-gray);
}
</style>
@@ -0,0 +1,128 @@
<script lang="ts">
import { mediaUrl } from "#lib/api/client.js";
import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
deleted?: boolean;
mark?: string;
message: MessageView | null;
prefix?: string;
}
let { message, deleted = false, mark = "", prefix = "" }: Props = $props();
const media = $derived(message?.media[0] ?? null);
const thumb = $derived(
media?.kind === "photo" && media.downloaded && media.id !== null
? mediaUrl(media.id)
: null
);
const kind = $derived.by(() => {
if (message?.poll) {
return "poll";
}
if (message?.contact) {
return "contact";
}
if (message?.location) {
return "location";
}
if (message?.sticker) {
return "sticker";
}
return media?.kind ?? (message?.has_media ? "other" : null);
});
const label = $derived(
message?.sticker?.emoji
? `${message.sticker.emoji} ${mediaKindLabel(kind)}`
: mediaKindLabel(kind)
);
const text = $derived(message?.text?.trim() || message?.poll?.question || "");
const lead = $derived.by(() => {
if (!label || (thumb && text)) {
return "";
}
return text ? `${label}, ` : label;
});
</script>
<span class="feed-text" class:deleted>
{#if mark}
<span class="mark"><Icon name="delete" size="0.875rem" /> {mark}</span>
{/if}
{#if prefix}
<span class="prefix">{prefix}: </span>
{/if}
{#if thumb}
<img alt="" class="thumb" decoding="async" loading="lazy" src={thumb}>
{/if}
{#if lead}
<span class="label">{lead}</span>
{/if}
{#if text}
<span class="body">{text}</span>
{:else if !lead}
<span class="empty">{t("feed.noText")}</span>
{/if}
</span>
<style lang="scss">
.feed-text {
overflow: hidden;
display: -webkit-box;
font-size: 0.9375rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
overflow-wrap: anywhere;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.prefix {
color: var(--color-text);
}
.mark {
margin-inline-end: 0.375rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
:global(.icon) {
vertical-align: -0.125rem;
}
}
.deleted .body,
.deleted .label {
text-decoration: line-through;
}
.thumb {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
margin-inline-end: 0.25rem;
border-radius: 0.25rem;
vertical-align: -0.25rem;
object-fit: cover;
}
.label {
color: var(--color-text);
}
.empty {
font-style: italic;
}
</style>
@@ -0,0 +1,93 @@
<script lang="ts">
import { onMount } from "svelte";
import type { FeedItem } from "#lib/api/types.js";
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
import FeedList from "#lib/components/feed/FeedList.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import {
type FeedFilter,
feed,
messageHref,
} from "#lib/stores/feed.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
interface Props {
filter: FeedFilter;
}
let { filter }: Props = $props();
function open(item: FeedItem, messageId: number) {
goto(messageHref(page.url, item.chat.chat_id, messageId));
}
onMount(() => {
feed.refresh().catch(() => undefined);
});
$effect(() => {
const { unseen } = feed;
if (unseen && unseen.count > 0) {
feed.markSeen(unseen.latest?.at).catch(() => undefined);
}
});
</script>
<div class="feed-view">
<header class="feed-header">
<Button
aria-label={t("common.back")}
onclick={() => feed.close(page.url)}
round
smaller
variant="translucent"
>
<Icon name="arrow-left" />
</Button>
<h2>{t("feed.title")}</h2>
</header>
<FeedFilters
chats={filter.chats}
kind={filter.kind}
onchats={(chats) => feed.filter(page.url, { ...filter, chats })}
onkind={(kind) => feed.filter(page.url, { ...filter, kind })}
/>
<div class="feed-body custom-scroll">
<FeedList chats={filter.chats} kind={filter.kind} onopen={open} />
</div>
</div>
<style lang="scss">
.feed-view {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.feed-header {
display: flex;
flex-shrink: 0;
gap: 0.75rem;
align-items: center;
height: var(--header-height);
padding: 0 0.625rem;
border-bottom: 1px solid var(--color-borders);
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
}
}
.feed-body {
overflow-y: auto;
flex: 1;
}
</style>
+5
View File
@@ -79,3 +79,8 @@ export function timeZoneLabel(): string {
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone() .find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
); );
} }
export function formatDayTime(iso: string): string {
const date = new Date(iso);
return `${format("day", date)}, ${format("time", date)}`;
}
+74
View File
@@ -0,0 +1,74 @@
export interface DiffPart {
changed: boolean;
text: string;
}
export interface WordDiff {
after: DiffPart[];
before: DiffPart[];
}
const TOKEN = /(\s+)/;
const MAX_TOKENS = 400;
function tokens(text: string): string[] {
return text.split(TOKEN).filter(Boolean);
}
function push(parts: DiffPart[], text: string, changed: boolean) {
const last = parts.at(-1);
if (last && last.changed === changed) {
last.text += text;
} else {
parts.push({ changed, text });
}
}
function lcsTable(a: string[], b: string[]): Uint16Array {
const width = b.length + 1;
const table = new Uint16Array((a.length + 1) * width);
for (let i = a.length - 1; i >= 0; i -= 1) {
for (let j = b.length - 1; j >= 0; j -= 1) {
table[i * width + j] =
a[i] === b[j]
? table[(i + 1) * width + j + 1] + 1
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
}
}
return table;
}
export function wordDiff(before: string, after: string): WordDiff | null {
const a = tokens(before);
const b = tokens(after);
if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) {
return null;
}
const table = lcsTable(a, b);
const width = b.length + 1;
const left: DiffPart[] = [];
const right: DiffPart[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
push(left, a[i], false);
push(right, b[j], false);
i += 1;
j += 1;
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
push(left, a[i], true);
i += 1;
} else {
push(right, b[j], true);
j += 1;
}
}
for (const token of a.slice(i)) {
push(left, token, true);
}
for (const token of b.slice(j)) {
push(right, token, true);
}
return { after: right, before: left };
}
+21 -18
View File
@@ -1,27 +1,30 @@
const MEDIA_KIND_LABELS: Record<string, string> = { import { t } from "#lib/i18n/locale.svelte.js";
animation: "GIF", import type { MessageKey } from "#lib/i18n/messages.js";
audio: "Аудио",
contact: "Контакт", const MEDIA_KIND_KEYS: Record<string, MessageKey> = {
dice: "Кубик", animation: "media.kind.gif",
document: "Файл", audio: "media.kind.audio",
game: "Игра", contact: "media.kind.contact",
gif: "GIF", dice: "media.kind.dice",
location: "Геопозиция", document: "media.kind.document",
photo: "Фото", game: "media.kind.game",
poll: "Опрос", gif: "media.kind.gif",
sticker: "Стикер", location: "media.kind.location",
story: "Сторис", photo: "media.kind.photo",
venue: "Геопозиция", poll: "media.kind.poll",
video: "Видео", sticker: "media.kind.sticker",
video_note: "Кружок", story: "media.kind.story",
voice: "Голосовое", venue: "media.kind.location",
video: "media.kind.video",
video_note: "media.kind.videoNote",
voice: "media.kind.voice",
}; };
export function mediaKindLabel(kind: string | null): string | null { export function mediaKindLabel(kind: string | null): string | null {
if (!kind) { if (!kind) {
return null; return null;
} }
return MEDIA_KIND_LABELS[kind] ?? "Медиа"; return t(MEDIA_KIND_KEYS[kind] ?? "media.kind.other");
} }
const BYTE_UNITS = ["B", "KB", "MB", "GB"]; const BYTE_UNITS = ["B", "KB", "MB", "GB"];
+2
View File
@@ -1,6 +1,7 @@
import { account } from "#lib/i18n/messages/account.js"; import { account } from "#lib/i18n/messages/account.js";
import { alerts } from "#lib/i18n/messages/alerts.js"; import { alerts } from "#lib/i18n/messages/alerts.js";
import { common } from "#lib/i18n/messages/common.js"; import { common } from "#lib/i18n/messages/common.js";
import { feed } from "#lib/i18n/messages/feed.js";
import { menu } from "#lib/i18n/messages/menu.js"; import { menu } from "#lib/i18n/messages/menu.js";
import { profile } from "#lib/i18n/messages/profile.js"; import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
@@ -11,6 +12,7 @@ export const messages = {
...account, ...account,
...alerts, ...alerts,
...common, ...common,
...feed,
...menu, ...menu,
...profile, ...profile,
...read, ...read,
+139
View File
@@ -0,0 +1,139 @@
import type { Messages } from "#lib/i18n/types.js";
export const feed = {
"chat.analytics": { en: "Analytics", ru: "Аналитика" },
"chat.backfill": {
en: "Fetch new messages",
ru: "Догрузить новые сообщения",
},
"chat.backfill.failed": {
en: "Couldn't start fetching new messages",
ru: "Не удалось начать догрузку",
},
"chat.backfill.started": {
en: "Fetching new messages",
ru: "Догружаем новые сообщения",
},
"chat.deletedAccount": { en: "Deleted account", ru: "Удалённый аккаунт" },
"chat.fallbackTitle": { en: "Chat {id}", ru: "Чат {id}" },
"chat.kind.channel": { en: "channel", ru: "канал" },
"chat.kind.group": { en: "group", ru: "группа" },
"chat.media": { en: "Media", ru: "Медиа" },
"chat.messages": {
en: { one: "{count} message", other: "{count} messages" },
ru: {
few: "{count} сообщения",
many: "{count} сообщений",
one: "{count} сообщение",
other: "{count} сообщения",
},
},
"chat.open": { en: "Open", ru: "Открыть" },
"chat.openProfile": { en: "Open profile", ru: "Открыть профиль" },
"chat.profile": { en: "Profile", ru: "Профиль" },
"chat.search": { en: "Search in chat", ru: "Поиск в чате" },
"chat.you": { en: "You", ru: "Вы" },
"chats.empty": { en: "No chats yet", ru: "Чатов пока нет" },
"chats.emptyFolder": { en: "Empty folder", ru: "Папка пуста" },
"chats.emptyFolderHint": {
en: "No chats match this folder yet",
ru: "В этой папке пока нет чатов",
},
"chats.foldersFailed": {
en: "Couldn't load folders",
ru: "Не удалось загрузить папки",
},
"chats.loadFailed": {
en: "Couldn't load chats",
ru: "Не удалось загрузить чаты",
},
"feed.after": { en: "Now", ru: "Стало" },
"feed.before": { en: "Was", ru: "Было" },
"feed.bulk": {
en: { one: "{count} message deleted", other: "{count} messages deleted" },
ru: {
few: "Удалено {count} сообщения",
many: "Удалено {count} сообщений",
one: "Удалено {count} сообщение",
other: "Удалено {count} сообщения",
},
},
"feed.changes": { en: "Changes", ru: "Изменения" },
"feed.chats.channel": { en: "Channels", ru: "Каналы" },
"feed.chats.dm": { en: "Private", ru: "Личка" },
"feed.chats.group": { en: "Groups", ru: "Группы" },
"feed.deleted": { en: "deleted", ru: "удалено" },
"feed.edited": { en: "edited", ru: "изменено" },
"feed.empty.all": {
en: "No deleted or edited messages yet",
ru: "Удалённых и изменённых пока нет",
},
"feed.empty.deleted": {
en: "No deleted messages yet",
ru: "Удалённых пока нет",
},
"feed.empty.edited": {
en: "No edited messages yet",
ru: "Изменённых пока нет",
},
"feed.error": {
en: "Couldn't load the feed",
ru: "Не удалось загрузить ленту",
},
"feed.filter.all": { en: "All", ru: "Все" },
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
"feed.filter.edited": { en: "Edited", ru: "Изменённые" },
"feed.lostText": {
en: "The earlier text wasn't saved",
ru: "Прежний текст не сохранился",
},
"feed.markSeen": { en: "Mark as seen", ru: "Отметить просмотренным" },
"feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" },
"feed.more": { en: "Show more", ru: "Показать ещё" },
"feed.noText": { en: "No text", ru: "Без текста" },
"feed.retry": { en: "Retry", ru: "Повторить" },
"feed.title": { en: "Feed", ru: "Лента" },
"feed.unseen": {
en: { one: "{count} new", other: "{count} new" },
ru: {
few: "{count} новых",
many: "{count} новых",
one: "{count} новое",
other: "{count} новых",
},
},
"feed.versions": { en: "All versions", ru: "Все версии" },
"media.kind.audio": { en: "Audio", ru: "Аудио" },
"media.kind.contact": { en: "Contact", ru: "Контакт" },
"media.kind.dice": { en: "Dice", ru: "Кубик" },
"media.kind.document": { en: "File", ru: "Файл" },
"media.kind.game": { en: "Game", ru: "Игра" },
"media.kind.gif": { en: "GIF", ru: "GIF" },
"media.kind.location": { en: "Location", ru: "Геопозиция" },
"media.kind.other": { en: "Media", ru: "Медиа" },
"media.kind.photo": { en: "Photo", ru: "Фото" },
"media.kind.poll": { en: "Poll", ru: "Опрос" },
"media.kind.sticker": { en: "Sticker", ru: "Стикер" },
"media.kind.story": { en: "Story", ru: "Сторис" },
"media.kind.video": { en: "Video", ru: "Видео" },
"media.kind.videoNote": { en: "Video message", ru: "Кружок" },
"media.kind.voice": { en: "Voice message", ru: "Голосовое" },
"panel.allStories": { en: "All stories", ru: "Все сторис" },
"panel.annotations": { en: "Notes", ru: "Заметки" },
"panel.callbacks": { en: "Callback data", ru: "Callback-данные" },
"panel.chatFiles": { en: "Chat files", ru: "Файлы чата" },
"panel.links": { en: "Links", ru: "Ссылки" },
"panel.reactions": { en: "Reactions", ru: "Реакции" },
"panel.search": { en: "Search", ru: "Поиск" },
"panel.trash": { en: "Trash", ru: "Корзина" },
"panel.versions": { en: "Versions", ru: "Версии" },
"thread.loadFailed": {
en: "Couldn't load messages",
ru: "Не удалось загрузить сообщения",
},
"thread.notFound": {
en: "This message isn't in the archive",
ru: "Этого сообщения нет в архиве",
},
"thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" },
} satisfies Messages;
+162
View File
@@ -0,0 +1,162 @@
import { getFeedUnseen, markFeedSeen } from "#lib/api/endpoints.js";
import type { FeedChatKind, FeedKind, FeedUnseen } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
import { browser } from "$app/env";
import { goto } from "$app/navigation";
const LIVE_DELAY = 600;
const KIND_PARAM = "feed";
const CHATS_PARAM = "chats";
const MESSAGE_PARAM = "message";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
export interface FeedFilter {
chats: FeedChatKind | null;
kind: FeedKind;
}
interface UrlLike {
readonly href: string;
}
export function feedFilterOf(url: UrlLike): FeedFilter | null {
const { searchParams } = new URL(url.href);
const kind = searchParams.get(KIND_PARAM);
if (!FEED_KINDS.includes(kind as FeedKind)) {
return null;
}
const chats = searchParams.get(CHATS_PARAM);
return {
chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind)
? (chats as FeedChatKind)
: null,
kind: kind as FeedKind,
};
}
function withFilter(url: UrlLike, filter: FeedFilter | null): URL {
const next = new URL(url.href);
next.searchParams.delete(KIND_PARAM);
next.searchParams.delete(CHATS_PARAM);
next.searchParams.delete(MESSAGE_PARAM);
if (filter) {
next.searchParams.set(KIND_PARAM, filter.kind);
if (filter.chats) {
next.searchParams.set(CHATS_PARAM, filter.chats);
}
}
return next;
}
function href(url: UrlLike): string {
const { pathname, search } = new URL(url.href);
return `${pathname}${search}`;
}
export function messageHref(
url: UrlLike,
chatId: number,
messageId: number
): string {
const next = withFilter(url, feedFilterOf(url));
next.pathname = `/app/${chatId}`;
next.searchParams.set(MESSAGE_PARAM, String(messageId));
return href(next);
}
export function withoutMessage(url: UrlLike): string {
const next = new URL(url.href);
next.searchParams.delete(MESSAGE_PARAM);
return href(next);
}
function createFeed() {
let unseen = $state<FeedUnseen | null>(null);
let revision = $state(0);
let account: number | null = null;
let openedHref: string | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
async function refresh() {
const id = accounts.selectedId;
if (id === null) {
unseen = null;
return;
}
const next = await getFeedUnseen();
if (accounts.selectedId === id) {
unseen = next;
}
}
function schedule() {
if (timer !== null) {
return;
}
timer = setTimeout(() => {
timer = null;
revision += 1;
refresh().catch(() => undefined);
}, LIVE_DELAY);
}
if (browser) {
events.subscribe((event) => {
if (event.type === "delete" || event.type === "edit") {
schedule();
}
});
events.onReconnect(schedule);
}
return {
async close(url: UrlLike) {
if (openedHref === href(url)) {
openedHref = null;
history.back();
return;
}
await goto(href(withFilter(url, null)), { replace: true, reset: false });
},
async filter(url: UrlLike, filter: FeedFilter) {
const wasOpened = openedHref === href(url);
const target = href(withFilter(url, filter));
if (wasOpened) {
openedHref = target;
}
await goto(target, { replace: true, reset: false });
},
async markSeen(at?: string) {
const id = accounts.selectedId;
const next = await markFeedSeen(at);
if (accounts.selectedId === id) {
unseen = next;
}
},
async open(url: UrlLike) {
const target = href(withFilter(url, { chats: null, kind: "all" }));
openedHref = target;
await goto(target, { reset: false });
},
refresh,
get revision() {
return revision;
},
sync() {
if (accounts.selectedId === account) {
return;
}
account = accounts.selectedId;
unseen = null;
refresh().catch(() => undefined);
},
get unseen() {
return unseen;
},
};
}
export const feed = createFeed();
+1
View File
@@ -1,4 +1,5 @@
export type RightPanel = export type RightPanel =
| "changes"
| "profile" | "profile"
| "search" | "search"
| "versions" | "versions"
+3 -3
View File
@@ -112,9 +112,9 @@
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)}; --color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)}; --color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};
--color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))}; --color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
--color-primary-opacity: rgba(var(--color-primary), 0.2); --color-primary-opacity: #{color.change($color-primary, $alpha: 0.2)};
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25); --color-primary-opacity-hover: #{color.change($color-primary, $alpha: 0.25)};
--color-primary-tint: rgba(var(--color-primary), 0.1); --color-primary-tint: #{color.change($color-primary, $alpha: 0.1)};
--color-active: #{$color-green}; --color-active: #{$color-green};
--color-active-darker: #{color.mix($color-green, $color-black, 84%)}; --color-active-darker: #{color.mix($color-green, $color-black, 84%)};
--color-success: #{$color-green}; --color-success: #{$color-green};
+10
View File
@@ -4,6 +4,7 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte"; import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte";
import FeedView from "#lib/components/feed/FeedView.svelte";
import MainMenu from "#lib/components/MainMenu.svelte"; import MainMenu from "#lib/components/MainMenu.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte"; import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte"; import RightColumn from "#lib/components/RightColumn.svelte";
@@ -18,6 +19,7 @@
import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js"; import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
import { feedFilterOf } from "#lib/stores/feed.svelte.js";
import { search } from "#lib/stores/search.svelte.js"; import { search } from "#lib/stores/search.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";
@@ -28,6 +30,7 @@
// biome-ignore lint/suspicious/noEqualsToNull: route params are undefined when absent // biome-ignore lint/suspicious/noEqualsToNull: route params are undefined when absent
const chatOpen = $derived(page.params.chatId != null); const chatOpen = $derived(page.params.chatId != null);
const feedFilter = $derived(feedFilterOf(page.url));
$effect(() => { $effect(() => {
if (!accounts.loaded) { if (!accounts.loaded) {
@@ -81,6 +84,13 @@
> >
<Settings /> <Settings />
</div> </div>
{:else if feedFilter}
<div
class="left-view"
in:fly={{ duration: 200, easing: cubicOut, x: 64 }}
>
<FeedView filter={feedFilter} />
</div>
{:else} {:else}
<div <div
class="left-view" class="left-view"
@@ -1,11 +1,16 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import ChatHeader from "#lib/components/ChatHeader.svelte"; import ChatHeader from "#lib/components/ChatHeader.svelte";
import MessageList from "#lib/components/MessageList.svelte"; import MessageList from "#lib/components/MessageList.svelte";
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 { withoutMessage } from "#lib/stores/feed.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
const chatId = $derived(Number(page.params.chatId)); const chatId = $derived(Number(page.params.chatId));
const linkedMessage = $derived(page.url.searchParams.get("message"));
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
@@ -14,6 +19,19 @@
chats.ensure(chatId); chats.ensure(chatId);
chats.enrich(chatId); chats.enrich(chatId);
}); });
$effect.pre(() => {
if (linkedMessage === null) {
return;
}
const messageId = Number(linkedMessage);
untrack(() => {
if (Number.isSafeInteger(messageId)) {
ui.requestJump(chatId, messageId);
}
goto(withoutMessage(page.url), { replace: true, reset: false });
});
});
</script> </script>
<div class="chat-view"> <div class="chat-view">