Compare commits

..
32 Commits
Author SHA1 Message Date
renovate Bot b9e8a96774 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 331fbb1 (main) (#22)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:11:41 +00:00
hh d660079c02 feat(backend,frontend): search everywhere in buttons, links and message data, a jump from search or an alert lights up the found words and buttons 2026-10-23 22:59:29 +00:00
hh acfa726e61 fix(backend,frontend): expired story keeps its kind and archive heals stories that lost it, phone inputs no longer zoom the page 2026-10-23 16:41:42 +02:00
hh d9378056f4 fix(frontend): chat header keeps the name readable on a phone, all its actions are in its menu, menus open below the pointer and stay on screen 2026-10-19 06:19:46 +00:00
hh c307426f4e fix(frontend): context menus stay inside a narrow screen 2026-10-19 06:12:48 +00:00
hh 977dee3cb2 chore(frontend): toasts lose the success kind nobody calls anymore 2026-10-19 06:07:54 +00:00
hh bca0b34636 feat(frontend): a finished action answers on the button that was pressed instead of a toast 2026-10-19 06:07:54 +00:00
hh e228d999dd fix(frontend): account phone stored with a plus is not shown with two 2026-10-19 05:47:09 +00:00
hh aff8a9e443 fix(frontend): empty media and links tabs keep a gap under the tabs instead of sticking to them 2026-10-19 05:38:24 +00:00
hh 25364f4523 feat(frontend): forwarded header, pinned bar, folder tabs, inline buttons, edit history and deleted accounts speak russian and english 2026-10-19 05:38:24 +00:00
hh 0cd34f3037 feat(frontend): profile media, files and links tabs speak russian and english 2026-10-19 05:38:24 +00:00
hh cff493f5dd feat(frontend): analytics panel and online statuses speak russian and english with real plurals 2026-10-19 05:37:44 +00:00
hh 543e6eaa50 feat(frontend): service messages speak russian and english 2026-10-19 05:32:34 +00:00
hh 7baa32f400 chore(frontend): notes dictionary drops a string the shared ui one replaced 2026-10-19 05:26:53 +00:00
hh e977ab77a8 fix(frontend): a filter written to the url survives another url write, the chat picked with alt and arrows stays in view 2026-10-19 05:26:20 +00:00
hh 8a1723e99f fix(frontend): panel history survives a link that rewrites its own url, dictionaries reuse the thread and panel strings of the feed 2026-10-19 05:26:20 +00:00
hh c06597aba2 chore(backend): chat stats migration follows the search migration 2026-10-19 05:26:11 +00:00
hh 7f3458b3ef docs: mcp tool list matches the server 2026-10-19 05:26:11 +00:00
hh 4a32e32114 feat(frontend): online dots on private chats, deleted last message struck through, back from a chat goes through history 2026-10-19 05:26:11 +00:00
hh 9c556ff8b9 feat(frontend): notes list with tag chips and the tag in the url, from the menu and settings, capture status dot on the menu and a line in settings 2026-10-19 05:26:11 +00:00
hh 171d8d922d feat(frontend): exact time on the message time, deletion and edit times in the meta, note marker, copy link answered in the menu, media menu keeps the message items, date pill opens the calendar, capture gaps between messages 2026-10-19 05:25:54 +00:00
hh 848d2aca1a feat(frontend): right panel lives in browser history and back closes it first, message panels close on chat switch, hotkeys for search, chats, in-chat search and closing 2026-10-19 05:25:54 +00:00
hh 7f7fc1296e feat(watcher): watched bots report capture uptime like the userbot 2026-10-19 05:25:44 +00:00
hh f62c345e2b feat(backend): who of the given people is online now in one request 2026-10-19 05:25:44 +00:00
hh 9ae8b34005 feat(backend): capture status and the stretches the archive was not recording 2026-10-19 05:25:44 +00:00
hh a0e134756c feat(backend): notes list across chats with the message and chat they belong to, filter by tag, tag counts 2026-10-19 05:25:44 +00:00
hh 97f9082f5e feat(backend): chat list marks a deleted last message, history pages mark messages with a note 2026-10-19 05:25:44 +00:00
hh 90b37ec708 fix(frontend): jobs name their chats, open links tab counts only links that are really open 2026-10-19 03:53:08 +00:00
hh 4082adba25 fix(frontend): buttons keep a gap between icon and text, viewer counter stays clear of the actions on mobile 2026-10-19 03:53:08 +00:00
hh b3805123b5 fix(frontend): english storage and policy labels fit on mobile, phone placeholder no longer peeks out from under its label 2026-10-19 03:53:08 +00:00
hh 896b89f3b0 refactor(frontend): stories archives drop tile styles and imports that live in the tile 2026-10-19 03:53:08 +00:00
hh 4a5c92f0e9 feat(frontend): storage, trash, policy, stories, shares, jobs, bots, media, login and notes speak russian and english 2026-10-19 03:53:08 +00:00
147 changed files with 5366 additions and 1276 deletions

No files matched your search

+2 -2
View File
@@ -11,7 +11,7 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out.
- **Presence** as a time series (TimescaleDB), read receipts, stories.
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
- **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client.
- **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
Nothing is ever deleted from the archive.
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
## MCP
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_feed`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. Two tools write: `set_watch`, and `download_media`, which pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. There are no send, edit or delete tools and there will not be.
## Run
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:331fbb158258608b9391e802305a9a2c9e42d689435c1d98706647122a122ab0
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,97 @@
"""chat stats last deleted
Revision ID: f1c4a8e2d6b3
Revises: d8f1a6c3e5b7
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "f1c4a8e2d6b3"
down_revision: str | None = "d8f1a6c3e5b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_APPLY = """
CREATE OR REPLACE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO chat_stats AS cs (account_id, chat_id, message_count,
last_date, last_message_id,
last_text, last_sender_id{column})
VALUES (NEW.account_id, NEW.chat_id, 1,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.date END,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.message_id END,
NEW.text, NEW.sender_id{value})
ON CONFLICT (account_id, chat_id) DO UPDATE SET
message_count = cs.message_count + 1,
last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_date ELSE cs.last_date END,
last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_message_id
ELSE cs.last_message_id END,
last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_text ELSE cs.last_text END,
last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_sender_id
ELSE cs.last_sender_id END{merge};
ELSE
UPDATE chat_stats
SET last_text = NEW.text, last_sender_id = NEW.sender_id{update}
WHERE account_id = NEW.account_id
AND chat_id = NEW.chat_id
AND last_message_id = NEW.message_id;
END IF;
RETURN NULL;
END;
$$
"""
_DELETED_PARTS = {
"column": ", last_deleted",
"value": ", NEW.deleted_at IS NOT NULL",
"merge": (
",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)"
" THEN EXCLUDED.last_deleted ELSE cs.last_deleted END"
),
"update": ", last_deleted = NEW.deleted_at IS NOT NULL",
"deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at",
}
_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "")
_UPDATE_TRIGGER = (
"CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages "
"FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id{deleted}) "
"EXECUTE FUNCTION chat_stats_apply()"
)
_BACKFILL = """
UPDATE chat_stats cs SET last_deleted = true
FROM messages m
WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id
AND m.message_id = cs.last_message_id AND m.date = cs.last_date
AND m.deleted_at IS NOT NULL
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false"
)
op.execute(_BACKFILL)
op.execute(_APPLY.format(**_DELETED_PARTS))
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS))
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS))
op.execute(_APPLY.format(**_PLAIN_PARTS))
op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted")
+2
View File
@@ -21,6 +21,7 @@ from api.routers import (
avatars,
backfill,
bots,
capture,
chats,
custom_emoji,
discover,
@@ -114,6 +115,7 @@ app.include_router(webhooks.router)
app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router)
app.include_router(capture.router)
app.mount("/mcp", mcp_app)
+21 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.read import annotations
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page
from utils.read.models import DEFAULT_LIMIT, AnnotationView, NoteTag, NoteView, Page
router = APIRouter(
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
@@ -42,6 +42,26 @@ async def list_annotations(
)
@router.get("/notes")
async def list_notes(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
tag: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[NoteView]:
return await annotations.list_notes(
pool, account_id, Page(limit=limit, offset=offset), tag=tag
)
@router.get("/tags")
async def list_tags(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[NoteTag]:
return await annotations.list_tags(pool, account_id)
@router.post("", status_code=201)
async def create_annotation(
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.read import capture
from utils.read.models import CaptureGap, CaptureStatus
router = APIRouter(prefix="/api/capture", tags=["capture"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
@router.get("/status")
async def capture_status(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> CaptureStatus:
return await capture.capture_status(pool, account_id)
@router.get("/gaps")
async def capture_gaps(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> list[CaptureGap]:
return await capture.capture_gaps(
pool, account_id, date_from=date_from, date_to=date_to
)
+11
View File
@@ -9,6 +9,7 @@ from api.timezone import checked_tz
from utils.read import presence, presence_stats
from utils.read.models import (
DEFAULT_LIMIT,
MAX_LIMIT,
Page,
PresenceHourly,
PresenceSample,
@@ -54,6 +55,16 @@ async def current_presence(
return await presence.current_presence(pool, account_id, peer_id)
@router.get("/online")
async def online_now(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_ids: Annotated[str, Query()],
) -> list[PresenceSample]:
parsed = [int(part) for part in peer_ids.split(",") if part.strip()]
return await presence.online_now(pool, account_id, parsed[:MAX_LIMIT])
@router.get("/hourly")
async def presence_hourly(
pool: FromDishka[asyncpg.Pool],
+19 -2
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.files import content_disposition, story_file_name
from utils.files import content_disposition, sniff_media_kind, story_file_name
from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
async def _with_kind(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
story: StoryView,
) -> StoryView:
if story.media_kind or not story.storage_key:
return story
if not storage.exists(story.storage_key):
return story
kind = sniff_media_kind(storage.head(story.storage_key))
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
return story.model_copy(update={"media_kind": kind})
@router.get("/stories")
async def list_stories(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StoryView]:
return await peers.get_stories(
stories = await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
)
return [await _with_kind(pool, storage, account_id, story) for story in stories]
@router.get("/stories/{peer_id}/{story_id}/media")
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
INSERT INTO stories
(account_id, peer_id, story_id, date, expire_date, caption, media_kind,
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now())
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
date = EXCLUDED.date,
expire_date = EXCLUDED.expire_date,
@@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
downloaded = stories.downloaded OR EXCLUDED.downloaded,
views = EXCLUDED.views,
pinned = EXCLUDED.pinned,
deleted = EXCLUDED.deleted,
deleted = false,
raw = EXCLUDED.raw
"""
_MARK_DELETED = """
INSERT INTO stories
(account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true
"""
async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
raw: str,
*,
pinned: bool,
deleted: bool,
downloaded: bool,
) -> None:
await pool.execute(
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
downloaded,
views,
pinned,
deleted,
raw,
)
async def mark_deleted(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> None:
await pool.execute(_MARK_DELETED, account_id, peer_id, story_id)
@@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int:
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story)
if story.deleted:
await repository.mark_deleted(
capture.pool, capture.account_id, peer_id, story.id
)
return
storage_key: str | None = None
file_size: int | None = None
downloaded = False
stored = await repository.is_downloaded(
capture.pool, capture.account_id, peer_id, story.id
)
if not (stored or story.deleted or story.media is None):
if not (stored or story.media is None):
data = await download_bytes(client, story)
if data is not None:
storage_key = capture.storage.put(data)
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
story.views,
raw_json(story.raw),
pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded,
)
+5
View File
@@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
def sniff_media_kind(head: bytes) -> str:
is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3")
return "video" if is_video else "photo"
def resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None
) -> str:
+72 -1
View File
@@ -1,9 +1,34 @@
import re
from collections import Counter
import asyncpg
from utils.read.models import AnnotationView, Page
from utils.read.chats import get_chats
from utils.read.models import AnnotationView, NoteTag, NoteView, Page
_COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at"
TAG = re.compile(r"(?<!\w)#(\w+)")
WORD = re.compile(r"\w+")
_NOTES = """
SELECT a.id, a.account_id, a.chat_id, a.message_id, a.text,
a.created_at, a.updated_at,
m.text AS message_text, m.date AS message_date,
COALESCE(m.has_media, false) AS message_has_media,
m.deleted_at IS NOT NULL AS message_deleted
FROM annotations a
LEFT JOIN LATERAL (
SELECT text, date, has_media, deleted_at FROM messages
WHERE account_id = a.account_id AND chat_id = a.chat_id
AND message_id = a.message_id
LIMIT 1
) m ON true
WHERE a.account_id = $1 AND ($4::text IS NULL OR a.text ~* $4)
ORDER BY a.created_at DESC, a.id DESC
LIMIT $2 OFFSET $3
"""
async def list_annotations(
pool: asyncpg.Pool,
@@ -70,3 +95,49 @@ async def update_annotation(
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
return result.endswith("1")
def _tag_pattern(tag: str | None) -> str | None:
if tag is None:
return None
return f"(^|[^[:alnum:]_])#{tag}($|[^[:alnum:]_])"
async def list_notes(
pool: asyncpg.Pool, account_id: int, page: Page, *, tag: str | None = None
) -> list[NoteView]:
if tag is not None and not WORD.fullmatch(tag):
return []
rows = await pool.fetch(
_NOTES, account_id, page.capped_limit, page.offset, _tag_pattern(tag)
)
chats = {
chat.chat_id: chat
for chat in await get_chats(
pool, account_id, list({row["chat_id"] for row in rows})
)
}
notes: list[NoteView] = []
for row in rows:
chat = chats.get(row["chat_id"])
notes.append(
NoteView(
**dict(row),
chat_title=chat.title if chat else None,
chat_has_avatar=chat.has_avatar if chat else False,
)
)
return notes
async def list_tags(pool: asyncpg.Pool, account_id: int) -> list[NoteTag]:
rows = await pool.fetch(
"SELECT text FROM annotations WHERE account_id = $1", account_id
)
counts: Counter[str] = Counter()
for row in rows:
counts.update({tag.lower() for tag in TAG.findall(row["text"])})
return [
NoteTag(tag=tag, count=count)
for tag, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))
]
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
import asyncpg
from utils.read.models import CaptureGap, CaptureStatus
_STATUS = """
SELECT started_at, seen_at, seen_at >= now() - interval '150 seconds' AS live
FROM capture_uptime WHERE account_id = $1
ORDER BY started_at DESC LIMIT 1
"""
_GAPS = """
SELECT seen_at AS start, next_start AS "end"
FROM (
SELECT seen_at,
lead(started_at) OVER (ORDER BY started_at) AS next_start
FROM capture_uptime WHERE account_id = $1
) stretches
WHERE next_start - seen_at > interval '30 minutes'
AND ($2::timestamptz IS NULL OR next_start >= $2)
AND ($3::timestamptz IS NULL OR seen_at <= $3)
ORDER BY seen_at
"""
async def capture_status(pool: asyncpg.Pool, account_id: int) -> CaptureStatus:
row = await pool.fetchrow(_STATUS, account_id)
if row is None:
return CaptureStatus(live=False, since=None, last_seen=None)
return CaptureStatus(
live=row["live"], since=row["started_at"], last_seen=row["seen_at"]
)
async def capture_gaps(
pool: asyncpg.Pool,
account_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[CaptureGap]:
rows = await pool.fetch(_GAPS, account_id, date_from, date_to)
return [CaptureGap(**dict(row)) for row in rows]
+23 -1
View File
@@ -80,7 +80,8 @@ _CHAT_ROWS = """
WITH ids AS ({ids})
SELECT ids.chat_id,
COALESCE(cs.message_count, 0) AS message_count,
cs.last_date, cs.last_text, cs.last_sender_id,
cs.last_date, cs.last_text, cs.last_sender_id, cs.last_message_id,
COALESCE(cs.last_deleted, false) AS last_deleted,
COALESCE(named.title,
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
p.username) AS title,
@@ -149,6 +150,8 @@ def _chat_item(row: asyncpg.Record) -> ChatListItem:
last_date=row["last_date"],
last_text=row["last_text"],
last_sender_id=row["last_sender_id"],
last_message_id=row["last_message_id"],
last_deleted=row["last_deleted"],
)
@@ -259,6 +262,7 @@ async def get_chat_history( # noqa: PLR0913
views.append(_build_album(members, media_by_key))
index = end
await _apply_read_status(pool, account_id, chat_id, views)
await _apply_notes(pool, account_id, chat_id, views)
return views
@@ -278,6 +282,7 @@ async def get_message(
raw = load_raw(row["raw"])
view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
await _apply_read_status(pool, account_id, chat_id, [view])
await _apply_notes(pool, account_id, chat_id, [view])
return view
@@ -301,6 +306,23 @@ async def _apply_read_status(
)
async def _apply_notes(
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
) -> None:
if not views:
return
rows = await pool.fetch(
"SELECT DISTINCT message_id FROM annotations "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = ANY($3::bigint[])",
account_id,
chat_id,
[view.message_id for view in views],
)
noted = {row["message_id"] for row in rows}
for view in views:
view.has_note = view.message_id in noted
def _build_album(
members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record],
+28
View File
@@ -38,6 +38,8 @@ class ChatListItem(BaseModel):
last_date: datetime | None
last_text: str | None
last_sender_id: int | None
last_message_id: int | None = None
last_deleted: bool = False
class DiscoverItem(BaseModel):
@@ -204,6 +206,7 @@ class MessageView(BaseModel):
is_animated_emoji: bool = False
read: bool = False
read_at: datetime | None = None
has_note: bool = False
class MessageVersionView(BaseModel):
@@ -611,6 +614,31 @@ class AnnotationView(BaseModel):
updated_at: datetime
class NoteView(AnnotationView):
chat_title: str | None
chat_has_avatar: bool
message_text: str | None
message_date: datetime | None
message_has_media: bool
message_deleted: bool
class NoteTag(BaseModel):
tag: str
count: int
class CaptureStatus(BaseModel):
live: bool
since: datetime | None
last_seen: datetime | None
class CaptureGap(BaseModel):
start: datetime
end: datetime
class WatchView(BaseModel):
id: int
account_id: int
+14 -1
View File
@@ -76,7 +76,7 @@ async def get_stories(
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
"storage_key, downloaded, views, pinned, deleted FROM stories "
f"WHERE {where} "
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} "
f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} "
f"OFFSET ${len(params)}",
*params,
)
@@ -95,3 +95,16 @@ async def get_story(
story_id,
)
return StoryView(**dict(row)) if row else None
async def set_story_kind(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str
) -> None:
await pool.execute(
"UPDATE stories SET media_kind = $4 "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
kind,
)
+23
View File
@@ -48,6 +48,29 @@ async def current_presence(
return PresenceSample(**dict(row)) if row is not None else None
_ONLINE = """
SELECT peer_id, ts, status, last_online_date, next_offline_date
FROM (
SELECT DISTINCT ON (peer_id)
peer_id, ts, status, last_online_date, next_offline_date
FROM presence
WHERE account_id = $1 AND peer_id = ANY($2::bigint[])
AND ts > now() - interval '1 hour'
ORDER BY peer_id, ts DESC
) latest
WHERE status = 'online' AND next_offline_date > now()
"""
async def online_now(
pool: asyncpg.Pool, account_id: int, peer_ids: list[int]
) -> list[PresenceSample]:
if not peer_ids:
return []
rows = await pool.fetch(_ONLINE, account_id, peer_ids)
return [PresenceSample(**dict(row)) for row in rows]
async def presence_hourly(
pool: asyncpg.Pool,
account_id: int,
+65
View File
@@ -0,0 +1,65 @@
import re
from collections.abc import Iterator
from typing import Any
from utils.search.models import Found, FoundWhere
HIDDEN_KEYS = ("chat", "from_user", "sender_chat")
SKIPPED_KEYS = ("_", "file_id", "file_unique_id")
ENUM = re.compile(r"^[A-Z][A-Za-z]+\.[A-Z_]+$")
_HEADS: dict[str, FoundWhere] = {
"text": "text",
"caption": "text",
"web_page": "preview",
}
_LEAVES: dict[str, FoundWhere] = {"url": "link", "file_name": "file"}
type Path = tuple[str | int, ...]
def _scalars(node: object, path: Path = ()) -> Iterator[tuple[Path, str, object]]:
if isinstance(node, dict):
items = ((k, v) for k, v in node.items() if k not in SKIPPED_KEYS)
elif isinstance(node, list):
items = enumerate(node)
else:
return
for key, value in items:
if isinstance(value, dict | list):
yield from _scalars(value, (*path, key))
elif isinstance(value, str | int | float) and not isinstance(value, bool):
yield (*path, key), str(value), node
def _where(path: Path) -> FoundWhere:
head, leaf = str(path[0]), str(path[-1])
if head == "reply_markup":
return "button"
if leaf in _LEAVES:
return _LEAVES[leaf]
if head.startswith("forward"):
return "forward"
if head.startswith("reply_to"):
return "reply"
return _HEADS.get(head, "data")
def find(raw: dict[str, Any], needle: str) -> Found | None:
folded = needle.casefold()
fallback = None
for path, value, owner in _scalars(raw):
if folded not in value.casefold() or ENUM.match(value):
continue
where = _where(path)
button = owner.get("text") if isinstance(owner, dict) else None
found = Found(
where=where,
path=".".join(map(str, path)),
value=value,
button=button if where == "button" else None,
)
if where != "text":
return found
fallback = fallback or found
return fallback
+17 -4
View File
@@ -10,7 +10,10 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1)
ChatKind = Literal["private", "group", "channel"]
HitSource = Literal["text", "stt", "version"]
HitSource = Literal["text", "stt", "version", "data"]
FoundWhere = Literal[
"text", "button", "link", "forward", "reply", "file", "preview", "data"
]
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
@@ -34,6 +37,7 @@ class SearchFilters(BaseModel):
date_from: datetime | None = None
date_to: datetime | None = None
regex: str | None = None
everywhere: bool = False
cursor: str | None = None
limit: int = DEFAULT_LIMIT
@@ -45,6 +49,13 @@ class SearchFilters(BaseModel):
return value
class Found(BaseModel):
where: FoundWhere
path: str
value: str
button: str | None = None
class SearchHit(BaseModel):
chat_id: int
message_id: int
@@ -56,12 +67,14 @@ class SearchHit(BaseModel):
edited_at: datetime | None
deleted_at: datetime | None
source: HitSource
extracted_text: str | None
version_text: str | None
version_at: datetime | None
extracted_text: str | None = None
version_text: str | None = None
version_at: datetime | None = None
found: Found | None = None
class SearchPage(BaseModel):
hits: list[SearchHit]
next_cursor: str | None
terms: list[str]
scanned_to: datetime | None = None
+95 -3
View File
@@ -1,5 +1,12 @@
import json
import re
from datetime import UTC, datetime, timedelta
from time import monotonic
import asyncpg
from utils.read.message_view import load_raw
from utils.search.found import ENUM, HIDDEN_KEYS, SKIPPED_KEYS, find
from utils.search.models import (
MAX_LIMIT,
ChatKind,
@@ -10,6 +17,12 @@ from utils.search.models import (
encode_cursor,
)
MIN_NEEDLE = 3
_WINDOW = timedelta(days=7)
_BUDGET_SECONDS = 2.5
_BOTTOM = -(2**63)
_LIKE_SPECIAL = re.compile(r"([\\%_])")
_PARSE = """
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
@@ -76,6 +89,24 @@ _WITH_MEDIA = (
_SOURCES = ("text", "stt", "version")
_OLDEST = "SELECT min(date) FROM message_search WHERE account_id = $1"
_VALUE_MATCH = (
'strict $.** ? (@.type() == "object").keyvalue() ? ('
+ "".join(f'@.key != "{key}" && ' for key in SKIPPED_KEYS)
+ '(@.value.type() == "string" || @.value.type() == "number") && '
f"!(@.value.string() like_regex {json.dumps(ENUM.pattern)}) && "
'@.value.string() like_regex {needle} flag "iq")'
)
_EVERYWHERE = (
f"SELECT {_COLS}, s.raw - {{hidden}}::text[] AS raw " # noqa: S608
"FROM messages s WHERE {where} "
"AND CASE WHEN s.raw::text ILIKE {like} "
"THEN jsonb_path_exists(s.raw - {hidden}::text[], {path}::jsonpath) "
f"ELSE false END ORDER BY {_KEY_ORDER} LIMIT {{limit}}"
)
class _Binder:
def __init__(self) -> None:
@@ -130,9 +161,72 @@ def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
async with pool.acquire() as conn, conn.transaction():
await conn.execute("SET LOCAL jit = off")
if filters.everywhere:
return await _everywhere(conn, filters)
return await _search(conn, filters)
async def _channels(conn: asyncpg.Connection, filters: SearchFilters) -> list[int]:
if filters.kind in {"group", "channel"}:
return await conn.fetchval(_CHANNELS, filters.account_id)
return []
def _data_hit(row: asyncpg.Record, needle: str) -> SearchHit:
data = dict(row)
raw = load_raw(data.pop("raw"))
return SearchHit(**data, source="data", found=find(raw, needle))
async def _everywhere(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
needle = (filters.query or "").strip()
floor = filters.date_from or await conn.fetchval(_OLDEST, filters.account_id)
if len(needle) < MIN_NEEDLE or floor is None:
return SearchPage(hits=[], next_cursor=None, terms=[])
channels = await _channels(conn, filters)
quoted = json.dumps(needle, ensure_ascii=False)
like = _LIKE_SPECIAL.sub(r"\\\1", quoted[1:-1])
path = _VALUE_MATCH.format(needle=quoted)
size = min(filters.limit, MAX_LIMIT)
cursor = filters.cursor
top = decode_cursor(cursor)[0] if cursor else None
hits: list[SearchHit] = []
started = monotonic()
while True:
lower = max((top or filters.date_to or datetime.now(UTC)) - _WINDOW, floor)
bind = _Binder()
conds = _conditions(
filters.model_copy(update={"cursor": cursor}), channels, bind
)
conds.append(f"s.date >= {bind(lower)}")
if top is not None:
conds.append(f"s.date <= {bind(top)}")
limit = size - len(hits)
sql = _EVERYWHERE.format(
where=" AND ".join(conds),
hidden=bind(list(HIDDEN_KEYS)),
like=bind(f"%{like}%"),
path=bind(path),
limit=bind(limit),
)
account = bind(filters.account_id)
rows = await conn.fetch(
_WITH_MEDIA.format(hits=sql, account=account), *bind.params
)
hits += [_data_hit(row, needle) for row in rows]
if len(rows) >= limit:
last = rows[-1]
top = last["date"]
cursor = encode_cursor(top, last["chat_id"], last["message_id"])
elif lower > floor:
top = lower
cursor = encode_cursor(lower, _BOTTOM, _BOTTOM)
else:
return SearchPage(hits=hits, next_cursor=None, terms=[], scanned_to=floor)
if len(hits) >= size or monotonic() - started > _BUDGET_SECONDS:
return SearchPage(hits=hits, next_cursor=cursor, terms=[], scanned_to=top)
async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
query = (filters.query or "").strip()
terms: list[str] = []
@@ -144,9 +238,7 @@ async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPag
return SearchPage(hits=[], next_cursor=None, terms=terms)
tsquery = parsed["tsquery"]
channels: list[int] = []
if filters.kind in {"group", "channel"}:
channels = await conn.fetchval(_CHANNELS, filters.account_id)
channels = await _channels(conn, filters)
bind = _Binder()
where = " AND ".join(_conditions(filters, channels, bind))
size = min(filters.limit, MAX_LIMIT)
+4
View File
@@ -24,6 +24,10 @@ class ContentAddressedStorage:
def get(self, key: str) -> bytes:
return self._path(key).read_bytes()
def head(self, key: str, size: int = 16) -> bytes:
with self._path(key).open("rb") as file:
return file.read(size)
def exists(self, key: str) -> bool:
return self._path(key).exists()
+6
View File
@@ -113,6 +113,12 @@ class BotRegistry:
def running(self) -> list[int]:
return list(self._running)
@property
def connected_accounts(self) -> list[int]:
return [
bot.account_id for bot in self._running.values() if bot.client.is_connected
]
async def sync(self) -> None:
async with self._lock:
await self._adopt_dropped_in()
+15 -3
View File
@@ -6,6 +6,7 @@ import asyncpg
import uvloop
from dependencies.container import container
from userbot.modules.uptime import repository as uptime
from utils.listen import Listener
from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL
@@ -16,6 +17,7 @@ from watcher.registry import BotRegistry
setup_logging()
RETRY_INTERVAL = 300
UPTIME_INTERVAL = 60
async def _retry_loop(registry: BotRegistry) -> None:
@@ -27,6 +29,14 @@ async def _retry_loop(registry: BotRegistry) -> None:
logger.exception("[red]Bot sync failed[/]")
async def _keep_uptime(registry: BotRegistry, pool: asyncpg.Pool) -> None:
while True:
for account_id in registry.connected_accounts:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
def on_change(_payload: str) -> None:
task = asyncio.create_task(registry.sync())
@@ -58,6 +68,7 @@ async def runner() -> None:
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry))
beat = asyncio.create_task(_keep_uptime(registry, pool))
try:
await registry.sync()
if not registry.running:
@@ -66,9 +77,10 @@ async def runner() -> None:
logger.info("[green]Bot watcher running.[/]")
await stop.wait()
finally:
retry.cancel()
with contextlib.suppress(asyncio.CancelledError):
await retry
for task in (retry, beat):
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await listener.stop()
await registry.close()
await container.close()
+6 -5
View File
@@ -1,13 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { PanelState } from "#lib/stores/ui.svelte.js";
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
interface PageState {
inApp?: boolean;
panel?: PanelState;
}
// interface Platform {}
}
}
export {};
+32
View File
@@ -0,0 +1,32 @@
import { request } from "#lib/api/client.js";
import type { PresenceSample } from "#lib/api/types.js";
export interface CaptureStatus {
last_seen: string | null;
live: boolean;
since: string | null;
}
export interface CaptureGap {
end: string;
start: string;
}
export function getCaptureStatus(accountId: number): Promise<CaptureStatus> {
return request<CaptureStatus>("/capture/status", {
query: { account_id: accountId },
});
}
export function getCaptureGaps(accountId: number): Promise<CaptureGap[]> {
return request<CaptureGap[]>("/capture/gaps", {
query: { account_id: accountId },
});
}
export function getOnline(peerIds: number[]): Promise<PresenceSample[]> {
return request<PresenceSample[]>("/presence/online", {
account: true,
query: { peer_ids: peerIds.join(",") },
});
}
+31
View File
@@ -0,0 +1,31 @@
import { request } from "#lib/api/client.js";
import type { Annotation } from "#lib/api/types.js";
export interface Note extends Annotation {
chat_has_avatar: boolean;
chat_title: string | null;
message_date: string | null;
message_deleted: boolean;
message_has_media: boolean;
message_text: string | null;
}
export interface NoteTag {
count: number;
tag: string;
}
export function listNotes(
tag: string | null,
offset: number,
limit: number
): Promise<Note[]> {
return request<Note[]>("/annotations/notes", {
account: true,
query: { limit, offset, tag },
});
}
export function listNoteTags(): Promise<NoteTag[]> {
return request<NoteTag[]>("/annotations/tags", { account: true });
}
+24 -1
View File
@@ -7,7 +7,24 @@ export type PolicyScopeType =
| "default_channel"
| "folder"
| "chat";
export type SearchSource = "text" | "stt" | "version";
export type SearchSource = "text" | "stt" | "version" | "data";
export type FoundWhere =
| "text"
| "button"
| "link"
| "forward"
| "reply"
| "file"
| "preview"
| "data";
export interface SearchFound {
button: string | null;
path: string;
value: string;
where: FoundWhere;
}
export type SearchChatKind = "private" | "group" | "channel";
export type JobStatus =
@@ -45,6 +62,8 @@ export interface Chat {
is_contact: boolean;
kind: ChatKind;
last_date: string | null;
last_deleted: boolean;
last_message_id: number | null;
last_sender_id: number | null;
last_text: string | null;
message_count: number;
@@ -196,6 +215,7 @@ export interface MessageView {
entities: EntityView[];
forward: ForwardView | null;
has_media: boolean;
has_note: boolean;
inline_buttons: InlineButton[][];
is_animated_emoji: boolean;
is_self_destruct: boolean;
@@ -286,6 +306,7 @@ export interface SearchHit {
deleted_at: string | null;
edited_at: string | null;
extracted_text: string | null;
found: SearchFound | null;
has_media: boolean;
media_kind: string | null;
message_id: number;
@@ -299,6 +320,7 @@ export interface SearchHit {
export interface SearchPage {
hits: SearchHit[];
next_cursor: string | null;
scanned_to: string | null;
terms: string[];
}
@@ -309,6 +331,7 @@ export interface SearchQuery {
date_to?: string;
deleted?: boolean;
edited?: boolean;
everywhere?: boolean;
has_media?: boolean;
kind?: SearchChatKind;
limit?: number;
@@ -2,6 +2,7 @@
import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { accountName, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
@@ -16,10 +17,12 @@
function nameOf(id: number | null): string {
if (id === null) {
return "Someone";
return t("service.someone");
}
if (id === ownId) {
return accounts.selected ? accountName(accounts.selected) : "You";
return accounts.selected
? accountName(accounts.selected)
: t("service.you");
}
const peer = peers.get(id);
return peer ? peerName(peer) : String(id);
@@ -44,35 +47,39 @@
const selfJoin =
members.length === 1 && members[0] === message.sender_id;
if (selfJoin) {
return `${actor} joined the group`;
return t("service.joined", { actor });
}
const names = members.map(nameOf).join(", ");
return names ? `${actor} added ${names}` : `${actor} joined the group`;
return names
? t("service.added", { actor, names })
: t("service.joined", { actor });
}
case "left_chat_members":
return `${actor} left the group`;
return t("service.left", { actor });
case "pinned_message":
return `${actor} pinned a message`;
return t("service.pinned", { actor });
case "group_chat_created":
return `${actor} created the group`;
return t("service.groupCreated", { actor });
case "channel_chat_created":
return "Channel created";
return t("service.channelCreated");
case "phone_call_ended":
return service.duration === null
? "Call ended"
: `Call ended · ${durationOf(service.duration)}`;
? t("service.callEnded")
: t("service.callEndedAfter", {
duration: durationOf(service.duration),
});
case "phone_call_started":
return "Call started";
return t("service.callStarted");
case "poll_option_added":
return `${actor} added a poll option`;
return t("service.pollOption", { actor });
case "new_chat_title":
return `${actor} changed the group name`;
return t("service.titleChanged", { actor });
case "new_chat_photo":
return `${actor} changed the group photo`;
return t("service.photoChanged", { actor });
case "delete_chat_photo":
return `${actor} removed the group photo`;
return t("service.photoRemoved", { actor });
default:
return service.kind.replace(/_/g, " ") || "Service message";
return t("service.unknown");
}
});
@@ -0,0 +1,44 @@
<script lang="ts">
import { Popover } from "bits-ui";
import ChatCalendar from "#lib/components/profile/ChatCalendar.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
anchor: HTMLElement | null;
chatId: number;
month: { month: number; year: number } | null;
open: boolean;
}
let { anchor, chatId, month, open = $bindable() }: Props = $props();
</script>
<Popover.Root bind:open>
<Popover.Portal>
<Popover.Content
aria-label={t("chat.calendar")}
class="bg-menu-content calendar-popover"
customAnchor={anchor}
side="bottom"
sideOffset={6}
>
{#key month}
<ChatCalendar
{chatId}
initial={month}
onjump={() => {
open = false;
}}
/>
{/key}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<style lang="scss">
:global(.calendar-popover) {
width: min(20rem, calc(100vw - 1.5rem));
padding: 0;
background-color: var(--color-background);
}
</style>
+37 -3
View File
@@ -29,8 +29,11 @@
const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId));
const DONE_DURATION = 2500;
let peer = $state<PeerView | null>(null);
let backfilling = $state(false);
let backfilled = $state(false);
async function backfill() {
if (backfilling) {
@@ -39,7 +42,10 @@
backfilling = true;
try {
await enqueueBackfill(chatId, true);
toasts.success(t("chat.backfill.started"));
backfilled = true;
setTimeout(() => {
backfilled = false;
}, DONE_DURATION);
} catch {
toasts.error(t("chat.backfill.failed"));
} finally {
@@ -48,6 +54,10 @@
}
function back() {
if (page.state.inApp) {
history.back();
return;
}
const target = new URL(page.url.href);
target.pathname = "/app";
goto(withoutMessage(target));
@@ -175,6 +185,16 @@
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
>{t("feed.changes")}</ContextMenuItem
>
<ContextMenuItem icon="stats" onselect={() => ui.openPanel("presence")}
>{t("chat.analytics")}</ContextMenuItem
>
<ContextMenuItem
icon={backfilled ? "check" : "cloud-download"}
onselect={backfill}
>{backfilled
? t("chat.backfill.started")
: t("chat.backfill")}</ContextMenuItem
>
<ContextMenuItem
icon="play-story"
onselect={() => ui.openPanel("stories")}
@@ -199,6 +219,7 @@
</Button>
<Button
aria-label={t("feed.changes")}
class="wide-only"
onclick={() => ui.openPanel("changes")}
round
smaller
@@ -216,14 +237,16 @@
<Icon name="stats" />
</Button>
<Button
aria-label={t("chat.backfill")}
aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
class="wide-only"
loading={backfilling}
onclick={backfill}
round
smaller
title={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
variant="translucent"
>
<Icon name="cloud-download" />
<Icon name={backfilled ? "check" : "cloud-download"} />
</Button>
</div>
</header>
@@ -275,6 +298,12 @@
align-items: center;
gap: 0.25rem;
margin-left: auto;
@media (max-width: 600px) {
:global(.wide-only) {
display: none;
}
}
}
.title {
@@ -287,8 +316,13 @@
}
.subtitle {
overflow: hidden;
display: block;
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.online {
color: var(--color-primary);
@@ -76,6 +76,27 @@
}
});
$effect(() => {
const id = activeChatId;
untrack(() => {
const index = list.findIndex((chat) => chat.chat_id === id);
if (index === -1 || viewport === null) {
return;
}
const row = viewport.querySelector<HTMLElement>(".Chat.selected");
if (row) {
row.scrollIntoView({ block: "nearest" });
return;
}
const top = index * rowHeight;
if (top < viewport.scrollTop) {
viewport.scrollTop = top;
} else if (top + rowHeight > viewport.scrollTop + viewport.clientHeight) {
viewport.scrollTop = top + rowHeight - viewport.clientHeight;
}
});
});
function measure(el: HTMLElement) {
({ scrollTop } = el);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
@@ -4,9 +4,11 @@
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { online } from "#lib/stores/online.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -31,6 +33,14 @@
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chat.chat_id }))
);
const person = $derived(chat.chat_id > 0 && !chat.is_bot);
const isOnline = $derived(person && online.isOnline(chat.chat_id));
$effect(() => {
if (person) {
online.watch(chat.chat_id);
}
});
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const showSender = $derived(
@@ -74,12 +84,14 @@
class:selected
use:ripple
>
<Avatar
avatar={{ id: chat.chat_id, kind: avatarKind }}
colorKey={chat.chat_id}
hasAvatar={chat.has_avatar}
name={title}
/>
<span class="avatar" class:online={isOnline}>
<Avatar
avatar={{ id: chat.chat_id, kind: avatarKind }}
colorKey={chat.chat_id}
hasAvatar={chat.has_avatar}
name={title}
/>
</span>
<div class="info">
<div class="info-row">
<h3 class="title">{title}</h3>
@@ -90,7 +102,13 @@
{#if senderPrefix}
<span class="sender">{senderPrefix}</span>
{/if}
{preview}
{#if chat.last_deleted}
<span class="deleted-mark" title={t("chat.deletedPreview")}
><Icon name="delete" size="1rem" /></span
><s class="deleted-text">{preview}</s>
{:else}
{preview}
{/if}
</span>
</div>
</div>
@@ -147,11 +165,15 @@
@media (hover: hover) {
&:hover {
--online-ring: var(--color-chat-hover);
background-color: var(--color-chat-hover);
}
}
&.selected {
--online-ring: var(--color-chat-active);
background-color: var(--color-chat-active);
.title,
@@ -163,6 +185,37 @@
}
}
.avatar {
position: relative;
display: flex;
flex-shrink: 0;
&.online::after {
content: "";
position: absolute;
right: 0.0625rem;
bottom: 0.0625rem;
width: 0.875rem;
height: 0.875rem;
border: 2px solid var(--online-ring, var(--color-background));
border-radius: 50%;
background-color: var(--color-green);
}
}
.deleted-mark {
display: inline-flex;
margin-inline-end: 0.1875rem;
vertical-align: -0.1875rem;
}
.deleted-text {
text-decoration: line-through;
}
.info {
overflow: hidden;
flex: 1;
+49 -2
View File
@@ -8,14 +8,16 @@
jumboEmojiCount,
linkHref,
} from "#lib/format/entities.js";
import { markText } from "#lib/format/search.js";
interface Props {
entities: EntityView[];
marks?: string[];
own?: boolean;
text: string;
}
let { text, entities, own = false }: Props = $props();
let { text, entities, marks = [], own = false }: Props = $props();
const nodes = $derived(buildEntityTree(text, entities));
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
@@ -32,13 +34,29 @@
)}
{#each items as node, i (i)}
{#if node.kind === "text"}
{node.text}
{@render plain(node.text)}
{:else}
{@render entity(node)}
{/if}
{/each}
{/snippet}
{#snippet plain(
value: string
)}
{#if marks.length === 0}
{value}
{:else}
{#each markText(value, marks) as segment, i (i)}
{#if segment.hit}
<mark class="found" class:own>{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}
{/if}
{/snippet}
{#snippet entity(
node: EntityNode
)}
@@ -186,6 +204,35 @@
background-color: var(--color-primary-tint);
}
.found {
padding: 0;
border-radius: 0.25rem;
color: inherit;
background-color: transparent;
animation: found-flash 3s ease;
--found-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
&.own {
--found-color: color-mix(
in srgb,
var(--color-own-links) 35%,
transparent
);
}
}
@keyframes found-flash {
0%,
70% {
background-color: var(--found-color);
}
100% {
background-color: transparent;
}
}
.spoiler {
cursor: pointer;
@@ -1,6 +1,7 @@
<script lang="ts">
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
@@ -10,7 +11,7 @@
}
const tabs = $derived<Tab[]>([
{ id: null, title: "All Chats" },
{ id: null, title: t("folders.all") },
...folders.list.map((folder) => ({
id: folder.folder_id,
title: folder.title,
@@ -93,7 +94,7 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
Политика захвата
{t("settings.policy")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
@@ -1,6 +1,7 @@
<script lang="ts">
import type { ForwardView } from "#lib/api/types.js";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
interface Props {
@@ -14,20 +15,20 @@
);
const name = $derived.by(() => {
if (forward.kind === "channel") {
return forward.chat_title ?? "Channel";
return forward.chat_title ?? t("forward.channel");
}
if (forward.kind === "hidden") {
return forward.from_name ?? "Hidden account";
return forward.from_name ?? t("forward.hidden");
}
if (peer) {
return peerName(peer);
}
return forward.from_name ?? "Unknown";
return forward.from_name ?? t("forward.unknown");
});
</script>
<div class="ForwardHeader">
<span class="label">Forwarded from</span>
<span class="label">{t("forward.from")}</span>
<span class="name">{name}</span>
{#if forward.signature}
<span class="signature">({forward.signature})</span>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { tick } from "svelte";
import { chats } from "#lib/stores/chats.svelte.js";
import { search } from "#lib/stores/search.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const EDITABLE = "input, textarea, select, [contenteditable='true']";
const LAYERS = "[role='dialog'], [role='alertdialog'], [role='menu']";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
async function focusIn(selector: string) {
await tick();
document.querySelector<HTMLInputElement>(selector)?.focus();
}
async function focusSearch() {
if (ui.leftView === "settings") {
ui.closeSettings();
}
await focusIn("#LeftColumn .left-header input");
}
function stepChat(step: number) {
const { list } = chats;
const index = list.findIndex((chat) => chat.chat_id === chatId);
if (index === -1 && step < 0) {
return;
}
const target = index === -1 ? list[0] : list[index + step];
if (target) {
goto(`/app/${target.chat_id}`);
}
}
function closeTop(editable: HTMLElement | null) {
if (editable) {
if (editable.closest("#LeftColumn .left-header")) {
search.close();
}
editable.blur();
} else if (ui.rightPanel !== null) {
ui.closePanel();
} else if (ui.leftView === "settings") {
ui.closeSettings();
} else if (chatId !== null) {
goto("/app");
}
}
function onCommand(event: KeyboardEvent): boolean {
const key = event.key.toLowerCase();
if (key === "k") {
focusSearch();
return true;
}
if (key === "f" && chatId !== null) {
ui.openPanel("search");
focusIn("#RightColumn input");
return true;
}
return false;
}
function onPlain(event: KeyboardEvent): boolean {
if (event.key === "/" && !event.altKey) {
focusSearch();
return true;
}
if (
event.altKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
stepChat(event.key === "ArrowUp" ? -1 : 1);
return true;
}
return false;
}
function onkeydown(event: KeyboardEvent) {
if (event.defaultPrevented || document.querySelector(LAYERS)) {
return;
}
const target = event.target as HTMLElement | null;
const editable = target?.closest<HTMLElement>(EDITABLE) ?? null;
const modified = event.ctrlKey || event.metaKey;
if (event.key === "Escape") {
closeTop(editable);
return;
}
const handled = modified
? !event.altKey && onCommand(event)
: editable === null && onPlain(event);
if (handled) {
event.preventDefault();
}
}
</script>
<svelte:window {onkeydown} />
@@ -1,12 +1,15 @@
<script lang="ts">
import type { InlineButton } from "#lib/api/types.js";
import { buttonHasMark } from "#lib/format/search.js";
import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
marks?: string[];
rows: InlineButton[][];
}
let { rows }: Props = $props();
let { marks = [], rows }: Props = $props();
let copied = $state<string | null>(null);
let resetTimer: ReturnType<typeof setTimeout> | undefined;
@@ -26,12 +29,14 @@
<div class="row">
{#each row as button, colIndex (colIndex)}
{@const key = `${rowIndex}:${colIndex}`}
{@const lit = marks.length > 0 && buttonHasMark(button, marks)}
{#if button.kind === "url" && button.url}
<a
class="button"
href={safeHref(button.url)}
rel="noopener"
target="_blank"
class:found={lit}
>
<span class="label">{button.text}</span>
<span class="corner">↗</span>
@@ -41,13 +46,14 @@
class="button"
onclick={() => copy(key, button.data ?? "")}
type="button"
class:found={lit}
>
<span class="label"
>{copied === key ? "Скопировано" : button.text}</span
>{copied === key ? t("inline.copied") : button.text}</span
>
</button>
{:else}
<span class="button static">
<span class="button static" class:found={lit}>
<span class="label">{button.text}</span>
</span>
{/if}
@@ -103,6 +109,27 @@
}
}
.found {
animation: found-flash 3s ease;
}
@keyframes found-flash {
0%,
70% {
background-color: color-mix(
in srgb,
var(--color-primary) 30%,
var(--color-message-reaction)
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
100% {
background-color: var(--color-message-reaction);
box-shadow: inset 0 0 0 1px transparent;
}
}
.label {
overflow: hidden;
text-overflow: ellipsis;
@@ -1,6 +1,7 @@
<script lang="ts">
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 { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,7 +21,7 @@
await accounts.load();
await goto("/app");
} catch {
toasts.error("Invalid token");
toasts.error(t("login.invalid"));
} finally {
busy = false;
}
@@ -33,16 +34,17 @@
<Icon name="lock" size="2.75rem" />
</div>
<h1>Beavergram</h1>
<p class="subtitle">Enter your access token to continue.</p>
<p class="subtitle">{t("login.subtitle")}</p>
<input
aria-label={t("login.token")}
autocomplete="current-password"
class="form-control"
placeholder="Access token"
placeholder={t("login.token")}
type="password"
bind:value
>
<Button disabled={busy || !value.trim()} loading={busy} type="submit">
Log in
{t("login.submit")}
</Button>
</form>
</div>
+29 -1
View File
@@ -5,10 +5,12 @@
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { captureLine } from "#lib/format/capture.js";
import { accountName } from "#lib/format/peer.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 { capture } from "#lib/stores/capture.svelte.js";
import { theme } from "#lib/stores/theme.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -23,6 +25,7 @@
const entries: Entry[] = [
{ icon: "eye", label: "settings.watches", panel: "watches" },
{ icon: "unmute", label: "settings.alerts", panel: "alerts" },
{ icon: "note", label: "notes.title", panel: "notes" },
{ icon: "data", label: "settings.storage", panel: "storage" },
{ icon: "play-story", label: "settings.stories", panel: "stories-all" },
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
@@ -31,6 +34,12 @@
let adding = $state(false);
const quiet = $derived(
capture.status !== null && !capture.status.live
? captureLine(capture.status, accounts.selected?.is_bot ?? false)
: null
);
function onkeydown(event: KeyboardEvent) {
const match = DIGIT.exec(event.code);
if (!(event.altKey && match)) {
@@ -54,14 +63,18 @@
<span class="menu-trigger">
<Button
{...props}
aria-label={t("menu.open")}
aria-label={quiet ? `${t("menu.open")}. ${quiet}` : t("menu.open")}
round
smaller
title={quiet ?? undefined}
variant="translucent"
>
<Icon name="menu" />
</Button>
<AlertBadge />
{#if quiet}
<span class="capture-dot"></span>
{/if}
</span>
{/snippet}
</DropdownMenu.Trigger>
@@ -142,6 +155,21 @@
flex-shrink: 0;
}
.capture-dot {
pointer-events: none;
position: absolute;
right: 0.125rem;
bottom: 0.125rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-error);
box-shadow: 0 0 0 2px var(--color-background);
}
:global(.main-menu) {
width: 17rem;
max-height: calc(100dvh - 5rem);
+35 -19
View File
@@ -15,7 +15,9 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -48,6 +50,7 @@
let loading = $state(false);
let saving = $state(false);
let token = 0;
const queued = createFlash<number>();
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
const isImage = $derived(mime.startsWith("image/") || kind === "photo");
@@ -61,7 +64,9 @@
const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready")
);
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
const title = $derived(
fileName ?? mediaKindLabel(kind) ?? t("media.kind.other")
);
const caption = $derived(items[index]?.caption);
function revoke() {
@@ -124,7 +129,7 @@
}
} catch {
if (current === token) {
toasts.error("Failed to load media");
toasts.error(t("media.loadFailed"));
}
} finally {
if (current === token) {
@@ -137,11 +142,12 @@
if (messageId === null) {
return;
}
const target = messageId;
try {
await fetchMedia(chatId, messageId);
toasts.success("Скачивание поставлено в очередь");
await fetchMedia(chatId, target);
queued.show(target);
} catch {
toasts.error("Не удалось поставить в очередь");
toasts.error(t("media.queueFailed"));
}
}
@@ -153,7 +159,7 @@
try {
await downloadMedia(currentMediaId);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("ui.downloadFailed"));
} finally {
saving = false;
}
@@ -225,7 +231,7 @@
{#if canSave}
{#if ontrash}
<button
aria-label="В корзину"
aria-label={t("storage.toTrash")}
class="media-action"
onclick={trash}
type="button"
@@ -239,7 +245,7 @@
})}
<button
{...props}
aria-label="Скачать файл"
aria-label={t("ui.downloadFile")}
class="media-action"
onclick={save}
type="button"
@@ -249,15 +255,15 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
{t("ui.downloadFile")}
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
{t("ui.shareLink")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
<button
aria-label="Доступ по ссылке"
aria-label={t("ui.shareLink")}
class="media-action"
onclick={share}
type="button"
@@ -265,7 +271,7 @@
<Icon name="allow-share" size="1.375rem" />
</button>
{/if}
<Dialog.Close aria-label="Закрыть" class="media-close">
<Dialog.Close aria-label={t("common.close")} class="media-close">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
@@ -276,7 +282,7 @@
{#if loading}
<Spinner color="white" />
{:else if result?.state === "ready" && isImage}
<img alt={kind} class="media-image" src={result.url}>
<img alt={title} class="media-image" src={result.url}>
{:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
@@ -301,24 +307,29 @@
{#if currentMediaId !== null}
<button class="media-download" onclick={save} type="button">
<Icon name="download" />
Скачать файл
{t("ui.downloadFile")}
</button>
{/if}
</div>
{:else if result?.state === "not-downloaded"}
<div class="media-message">
<p>Файл ещё не скачан в архив.</p>
<p>{t("media.notDownloaded")}</p>
<Button fluid onclick={queueFetch} variant="primary">
Скачать в архив
{#if queued.current === messageId}
<Icon name="check" />
{t("chat.media.queued")}
{:else}
{t("media.downloadToArchive")}
{/if}
</Button>
</div>
{:else if result?.state === "missing"}
<p class="media-message">Файл не найден.</p>
<p class="media-message">{t("media.missing")}</p>
{/if}
</div>
{#if hasNav}
<button
aria-label="Previous"
aria-label={t("ui.previous")}
class="media-nav prev"
disabled={index === 0}
onclick={() => step(-1)}
@@ -327,7 +338,7 @@
<Icon name="previous" size="1.75rem" />
</button>
<button
aria-label="Next"
aria-label={t("ui.next")}
class="media-nav next"
disabled={index === items.length - 1}
onclick={() => step(1)}
@@ -397,6 +408,11 @@
font-size: 0.9375rem;
color: var(--color-white);
@media (max-width: 600px) {
top: auto;
bottom: 1.25rem;
}
}
:global(.media-close) {
@@ -15,14 +15,16 @@
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import CopyItem from "#lib/components/ui/CopyItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import WebPage from "#lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
import { buttonHasMark, hasMark } from "#lib/format/search.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation";
@@ -30,7 +32,7 @@
interface Props {
animate: boolean;
firstInGroup: boolean;
highlighted: boolean;
highlight: string[] | null;
isGroupChat: boolean;
lastInGroup: boolean;
message: MessageView;
@@ -46,7 +48,7 @@
isGroupChat,
firstInGroup,
lastInGroup,
highlighted,
highlight,
animate,
onjump,
onmedia,
@@ -54,6 +56,14 @@
}: Props = $props();
const deleted = $derived(message.deleted_at !== null);
const marks = $derived(highlight ?? []);
const markedInside = $derived(
marks.length > 0 &&
(hasMark(message.text, marks) ||
message.inline_buttons.some((row) =>
row.some((button) => buttonHasMark(button, marks))
))
);
const hasText = $derived(Boolean(message.text));
const special = $derived(
Boolean(
@@ -137,13 +147,8 @@
onjump(message.reply.message_id);
}
}
async function copyText() {
await navigator.clipboard.writeText(message.text ?? "");
toasts.success(t("thread.copiedText"));
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success(t("thread.copiedId"));
function messageLink(): string {
return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
}
function openSenderProfile() {
if (senderChat === message.chat_id) {
@@ -166,7 +171,7 @@
data-message-id={message.message_id}
class:deleted
class:first-in-group={firstInGroup}
class:highlighted
class:highlighted={highlight !== null && !markedInside}
class:last-in-group={lastInGroup}
class:own
class:with-avatar={isGroupChat}
@@ -212,12 +217,6 @@
{#if message.reply}
<ReplyHeader {onjump} reply={message.reply} />
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
{t("thread.deleted")}
</span>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
@@ -236,13 +235,20 @@
{own}
/>
{:else if message.has_media}
<MessageMedia {message} onopen={() => onmedia(0)} {own} />
<MessageMedia
items={menu}
{message}
onopen={() => onmedia(0)}
{onversions}
{own}
/>
{/if}
{/if}
{#if hasText}
<div class="text">
<EntityText
entities={message.entities}
{marks}
{own}
text={message.text ?? ""}
/>
@@ -266,9 +272,15 @@
{t("thread.links", { count: linkCount })}
</button>
{/if}
<MessageMeta {message} {onversions} {own} />
<MessageMeta
{message}
noted={notes.has(message)}
onnote={openAnnotations}
{onversions}
{own}
/>
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
<InlineButtons {marks} rows={message.inline_buttons} />
{/if}
{#if hasCallbacks}
<button class="detail-chip" onclick={openCallbacks} type="button">
@@ -319,14 +331,26 @@
>{t("thread.toReply")}</ContextMenuItem
>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
label={t("chat.menu.copyLink")}
text={messageLink}
/>
{#if hasText}
<ContextMenuItem icon="copy" onselect={copyText}
>{t("thread.copyText")}</ContextMenuItem
>
<CopyItem
done={t("chat.menu.copied")}
icon="copy"
label={t("thread.copyText")}
text={() => message.text ?? ""}
/>
{/if}
<ContextMenuItem icon="hashtag" onselect={copyId}
>{t("thread.copyId")}</ContextMenuItem
>
<CopyItem
done={t("chat.menu.copied")}
icon="hashtag"
label={t("thread.copyId")}
text={() => String(message.message_id)}
/>
{#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile}
>{t("thread.senderProfile")}</ContextMenuItem
@@ -454,17 +478,6 @@
text-decoration: line-through;
}
.deleted-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
}
.detail-chip {
display: inline-flex;
+142 -20
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { tick, untrack } from "svelte";
import type { CaptureGap } from "#lib/api/capture.js";
import { listMessages } from "#lib/api/endpoints.js";
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
import type { LiveEvent, MessageView } from "#lib/api/types.js";
import ActionMessage from "#lib/components/ActionMessage.svelte";
import CalendarPopover from "#lib/components/CalendarPopover.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import MessageBubble from "#lib/components/MessageBubble.svelte";
import MessageVersions from "#lib/components/MessageVersions.svelte";
@@ -11,9 +13,10 @@
import TrackChat from "#lib/components/TrackChat.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDay } from "#lib/format/datetime.js";
import { formatDay, formatSpanRange } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
@@ -34,6 +37,8 @@
const STICK_OFFSET = 9;
const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360;
const SETTLE_DELAY = 400;
const HIGHLIGHT_MS = 3000;
let messages = $state<MessageView[]>([]);
let loading = $state(true);
@@ -45,6 +50,7 @@
let suppressAppear = $state(false);
let scrolling = $state(false);
let stuckDay = $state<string | null>(null);
let passedDays = $state.raw(new Set<string>());
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false);
@@ -52,7 +58,10 @@
let mediaIndex = $state(0);
let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null);
let highlight = $state<{ id: number; marks: string[] } | null>(null);
let calendarOpen = $state(false);
let calendarAnchor = $state<HTMLElement | null>(null);
let calendarMonth = $state<{ month: number; year: number } | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
@@ -66,6 +75,8 @@
dayKey: string;
daySeparator: string | null;
firstInGroup: boolean;
gapsAfter: CaptureGap[];
gapsBefore: CaptureGap[];
lastInGroup: boolean;
message: MessageView;
own: boolean;
@@ -75,6 +86,27 @@
return new Date(iso).toDateString();
}
const gapSpans = $derived(
capture.gaps.map((gap) => ({
day: dayKey(gap.start),
end: Date.parse(gap.end),
gap,
start: Date.parse(gap.start),
}))
);
function gapsBetween(
prev: MessageView | undefined,
message: MessageView
): typeof gapSpans {
if (!prev || gapSpans.length === 0) {
return [];
}
const from = Date.parse(prev.date);
const to = Date.parse(message.date);
return gapSpans.filter((span) => span.start >= from && span.end <= to);
}
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
for (let i = 0; i < messages.length; i += 1) {
@@ -82,15 +114,28 @@
const prev = messages[i - 1];
const next = messages[i + 1];
const day = dayKey(message.date);
const gaps = gapsBetween(prev, message);
const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({
dayKey: day,
daySeparator: samePrevDay ? null : formatDay(message.date),
firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
!prev ||
prev.sender_id !== message.sender_id ||
!samePrevDay ||
gaps.length > 0,
gapsAfter: gaps
.filter((span) => span.day === day)
.map((span) => span.gap),
gapsBefore: gaps
.filter((span) => span.day !== day)
.map((span) => span.gap),
lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay,
!next ||
next.sender_id !== message.sender_id ||
!sameNextDay ||
gapsBetween(message, next).length > 0,
message,
own: ownId !== null && message.sender_id === ownId,
});
@@ -185,7 +230,11 @@
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
}
function jumpToMessage(messageId: number, smooth = true) {
function jumpToMessage(
messageId: number,
smooth = true,
marks: string[] = []
) {
const owner = ownerOf(messageId) ?? messageId;
const target = container?.querySelector<HTMLElement>(
`[data-message-id="${owner}"]`
@@ -198,13 +247,13 @@
behavior: smooth ? "smooth" : "auto",
block: "center",
});
highlightId = owner;
highlight = { id: owner, marks };
if (highlightTimer) {
clearTimeout(highlightTimer);
}
highlightTimer = setTimeout(() => {
highlightId = null;
}, 1600);
highlight = null;
}, HIGHLIGHT_MS);
}
function scrollToBottom() {
@@ -219,13 +268,14 @@
}
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
let day: string | null = null;
const stuck: string[] = [];
for (const sep of seps) {
if (sep.getBoundingClientRect().top <= limit) {
day = sep.dataset.day ?? null;
if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) {
stuck.push(sep.dataset.day);
}
}
stuckDay = day;
stuckDay = stuck.pop() ?? null;
passedDays = new Set(stuck);
}
async function loadInitial() {
@@ -238,7 +288,7 @@
ui.clearJump();
await loadAround(pending.messageId);
await tick();
jumpToMessage(pending.messageId, false);
jumpToMessage(pending.messageId, false, pending.marks);
return;
}
const page = await listMessages(chatId, {
@@ -252,6 +302,7 @@
await tick();
scrollToBottom();
updateStuck();
setTimeout(updateStuck, SETTLE_DELAY);
} catch {
toasts.error(t("thread.loadFailed"));
} finally {
@@ -394,6 +445,13 @@
}
}
function openCalendar(event: MouseEvent, iso: string) {
const date = new Date(iso);
calendarAnchor = event.currentTarget as HTMLElement;
calendarMonth = { month: date.getUTCMonth(), year: date.getUTCFullYear() };
calendarOpen = true;
}
function openMedia(message: MessageView, index: number) {
mediaItems = viewerItemsFrom(message.message_id, message.media);
mediaIndex = index;
@@ -521,14 +579,14 @@
}
}
async function goTo(messageId: number) {
async function goTo(messageId: number, marks: string[] = []) {
try {
const loaded = ownerOf(messageId) !== null;
if (!loaded) {
await loadAround(messageId);
}
await tick();
jumpToMessage(messageId, loaded);
jumpToMessage(messageId, loaded, marks);
} catch {
toasts.error(t("thread.loadFailed"));
}
@@ -540,7 +598,7 @@
return;
}
ui.clearJump();
goTo(target.messageId);
goTo(target.messageId, target.marks);
});
$effect(() => {
@@ -581,22 +639,38 @@
<div class="loading-older"><Spinner /></div>
{/if}
{#each rows as row (row.message.message_id)}
{#each row.gapsBefore as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.daySeparator}
<div
class="day-separator"
data-day={row.dayKey}
class:idle={!scrolling && row.dayKey === stuckDay}
class:passed={passedDays.has(row.dayKey)}
>
<span>{row.daySeparator}</span>
<button
aria-label={t("chat.calendar")}
onclick={(event) => openCalendar(event, row.message.date)}
title={t("chat.calendar")}
type="button"
>
{row.daySeparator}
</button>
</div>
{/if}
{#each row.gapsAfter as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.message.service}
<ActionMessage message={row.message} onjump={goTo} />
{:else}
<MessageBubble
animate={!suppressAppear}
firstInGroup={row.firstInGroup}
highlighted={highlightId === row.message.message_id}
highlight={highlight?.id === row.message.message_id
? highlight.marks
: null}
{isGroupChat}
lastInGroup={row.lastInGroup}
message={row.message}
@@ -625,6 +699,20 @@
{/if}
</div>
{#snippet gapLine(
gap: CaptureGap
)}
<div class="capture-gap">
<span>{t("chat.gap", { range: formatSpanRange(gap.start, gap.end) })}</span>
</div>
{/snippet}
<CalendarPopover
anchor={calendarAnchor}
{chatId}
month={calendarMonth}
bind:open={calendarOpen}
/>
<MediaViewer
{chatId}
items={mediaItems}
@@ -714,10 +802,15 @@
justify-content: center;
margin: 0.75rem 0;
span {
button {
pointer-events: auto;
cursor: pointer;
padding: 0.25rem 0.75rem;
border: 0;
border-radius: 1rem;
font: inherit;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
@@ -725,10 +818,39 @@
background-color: var(--color-default-shadow);
backdrop-filter: blur(8px);
transition: opacity 0.3s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&.idle span {
&.idle button,
&.passed button {
pointer-events: none;
opacity: 0;
}
}
.capture-gap {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
&::before,
&::after {
content: "";
flex: 1;
border-top: 1px dashed var(--color-borders);
}
span {
flex-shrink: 0;
}
}
</style>
+55 -35
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { ContextMenu as Menu } from "bits-ui";
import type { Snippet } from "svelte";
import { visible } from "#lib/actions/visible.js";
import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia } from "#lib/api/endpoints.js";
@@ -14,24 +16,25 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import VideoNote from "#lib/components/media/VideoNote.svelte";
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
interface Props {
items?: Snippet;
message: MessageView;
onopen: () => void;
onversions?: () => void;
own: boolean;
}
let { message, onopen, own }: Props = $props();
let { items, message, onopen, onversions, own }: Props = $props();
const POLL_TRIES = 5;
const POLL_DELAY = 3000;
@@ -39,6 +42,7 @@
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
let queued = $state(false);
let saving = $state(false);
const ref = $derived(message.media[0] ?? null);
@@ -71,7 +75,10 @@
const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
);
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл");
const fileName = $derived(
ref?.file_name ?? mediaKindLabel(kind) ?? t("chat.media.file")
);
const waiting = $derived(queuing || queued);
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -101,16 +108,16 @@
}
async function queue() {
if (queuing) {
if (waiting) {
return;
}
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь");
queued = true;
poll();
} catch {
toasts.error("Не удалось поставить в очередь");
toasts.error(t("chat.media.queueFailed"));
} finally {
queuing = false;
}
@@ -124,7 +131,7 @@
try {
await downloadMedia(storedId);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("chat.media.downloadFailed"));
} finally {
saving = false;
}
@@ -145,7 +152,7 @@
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span>
<span>{t("chat.media.selfDestruct")}</span>
</button>
{:else if asFile && ref}
<FileChip media={ref} {own} />
@@ -165,11 +172,11 @@
<AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}>
<img alt={t("chat.media.photo")} src={ready.url}>
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img alt="sticker" src={ready.url}>
<img alt={fileName} src={ready.url}>
</button>
{:else if ready && isVideoSticker}
<video
@@ -205,46 +212,59 @@
</button>
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
<span
>{vk === "video"
? t("chat.media.video")
: t("chat.media.photo")}</span
>
<small
>{waiting
? t("chat.media.queued")
: t("chat.media.tapToDownload")}</small
>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
<Icon name={waiting ? "timer" : "download"} size="1.25rem" />
<span
>{waiting
? t("chat.media.queued")
: t("chat.media.download", { kind: label })}</span
>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Медиа</span>
<span>{t("chat.media")}</span>
</button>
{/if}
</div>
{/snippet}
{#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
>{t("chat.media.fullscreen")}</ContextMenuItem
>
{#if onversions}
<ContextMenuItem icon="recent" onselect={onversions}
>{t("chat.media.versions")}</ContextMenuItem
>
{/if}
{#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}>
Скачать в архив
</ContextMenuItem>
<ContextMenuItem icon="cloud-download" onselect={queue}
>{t("chat.media.queue")}</ContextMenuItem
>
{:else}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={save}
>{t("chat.media.save")}</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={share}
>{t("chat.media.share")}</ContextMenuItem
>
{/if}
{#if items}
<Menu.Separator class="bg-menu-separator" />
{@render items()}
{/if}
{/snippet}
</ContextMenu>
+126 -19
View File
@@ -1,30 +1,86 @@
<script lang="ts">
import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatTime } from "#lib/format/datetime.js";
import {
formatExact,
formatStamp,
formatTime,
} from "#lib/format/datetime.js";
import { readTitle } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
message: MessageView;
noted?: boolean;
onnote?: () => void;
onversions?: () => void;
own?: boolean;
}
let { message, onversions, own = false }: Props = $props();
let {
message,
noted = false,
onnote,
onversions,
own = false,
}: Props = $props();
const TIP_DURATION = 3000;
let tip = $state(false);
let tipTimer: ReturnType<typeof setTimeout> | undefined;
const exact = $derived(formatExact(message.date));
function toggleTip() {
tip = !tip;
clearTimeout(tipTimer);
if (tip) {
tipTimer = setTimeout(() => {
tip = false;
}, TIP_DURATION);
}
}
</script>
<span class="MessageMeta">
{#if message.edited_at}
{#if noted}
<button
class="edited"
onclick={onversions}
title="View edit history"
aria-label={t("chat.note")}
class="meta-button note"
onclick={onnote}
title={t("chat.note")}
type="button"
>
edited
<Icon name="note" size="1rem" />
</button>
{/if}
<span class="time">{formatTime(message.date)}</span>
{#if message.deleted_at}
<span class="deleted" title={formatExact(message.deleted_at)}>
{t("chat.deletedAt", { when: formatStamp(message.deleted_at) })}
</span>
{/if}
{#if message.edited_at}
<button
class="meta-button edited"
onclick={onversions}
title={t("chat.editedAt", { when: formatExact(message.edited_at) })}
type="button"
>
{t("chat.edited")}
</button>
{/if}
<button
class="meta-button time"
onclick={toggleTip}
title={exact}
type="button"
>
{formatTime(message.date)}
</button>
{#if tip}
<span class="tip" role="status">{exact}</span>
{/if}
{#if own}
<span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
@@ -34,6 +90,8 @@
<style lang="scss">
.MessageMeta {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.25rem;
@@ -47,21 +105,70 @@
user-select: none;
}
.time {
.meta-button {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
border-radius: 0.25rem;
font: inherit;
color: inherit;
white-space: nowrap;
background: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.note {
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.edited,
.deleted {
font-style: italic;
white-space: nowrap;
}
.deleted::after {
content: "·";
margin-inline-start: 0.25rem;
font-style: normal;
}
.tip {
pointer-events: none;
position: absolute;
right: 0;
bottom: calc(100% + 0.375rem);
z-index: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-style: normal;
color: var(--color-white);
white-space: nowrap;
background-color: var(--color-toast-background);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
}
.ticks {
display: inline-flex;
}
.edited {
cursor: pointer;
padding: 0;
border: 0;
font-size: 0.75rem;
font-style: italic;
color: inherit;
background: transparent;
}
</style>
@@ -6,6 +6,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
@@ -33,7 +34,7 @@
versions = text;
mediaVersions = media;
})
.catch(() => toasts.error("Failed to load edit history"))
.catch(() => toasts.error(t("versions.failed")))
.finally(() => {
loading = false;
});
@@ -46,8 +47,8 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Edit history</Dialog.Title>
<Dialog.Close aria-label="Close" class="dialog-close">
<Dialog.Title class="dialog-title">{t("versions.title")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
@@ -55,11 +56,11 @@
{#if loading}
<div class="centered"><Spinner /></div>
{:else if versions.length === 0 && mediaVersions.length === 0}
<p class="empty">No versions recorded.</p>
<p class="empty">{t("versions.none")}</p>
{:else}
{#if mediaVersions.length > 0}
<div class="media-versions">
<span class="media-label">Media versions</span>
<span class="media-label">{t("versions.media")}</span>
<div class="media-strip">
{#each mediaVersions as media (media.id)}
<MediaVersionThumb version={media} />
@@ -70,12 +71,14 @@
{#each versions as version, index (version.observed_at)}
<div class="version">
<div class="version-meta">
<span class="version-label">Version {index + 1}</span>
<span class="version-label"
>{t("versions.version", { number: index + 1 })}</span
>
<span class="version-date"
>{formatFull(version.observed_at)}</span
>
</div>
<div class="version-text">{version.text ?? "(no text)"}</div>
<div class="version-text">{version.text ?? t("feed.noText")}</div>
</div>
{/each}
{/if}
+3 -2
View File
@@ -3,6 +3,7 @@
import type { PinnedView } 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";
import { accounts } from "#lib/stores/accounts.svelte.js";
interface Props {
@@ -19,7 +20,7 @@
? (pinned.text ??
(pinned.media_kind
? mediaKindLabel(pinned.media_kind)
: "Pinned message"))
: t("pinned.message")))
: ""
);
@@ -56,7 +57,7 @@
>
<Icon name="pin" size="1.125rem" />
<span class="body">
<span class="label">Pinned message</span>
<span class="label">{t("pinned.message")}</span>
<span class="preview">{preview}</span>
</span>
</button>
+10 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
import NotesPanel from "#lib/components/annotations/NotesPanel.svelte";
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
@@ -25,6 +26,7 @@
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -37,6 +39,7 @@
changes: "feed.changes",
jobs: "settings.jobs",
links: "panel.links",
notes: "notes.title",
policy: "settings.policy",
presence: "chat.analytics",
profile: "chat.profile",
@@ -49,7 +52,6 @@
"storage-trash": "panel.trash",
stories: "settings.stories",
"stories-all": "panel.allStories",
versions: "panel.versions",
watches: "settings.watches",
webhooks: "watches.webhooks",
};
@@ -65,6 +67,9 @@
if (ui.rightPanel === null) {
return "";
}
if (ui.rightPanel === "notes" && notes.tag !== null) {
return t("notes.tag", { tag: notes.tag });
}
const chat =
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId)
@@ -139,6 +144,10 @@
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
<AnnotationsPanel />
{:else if ui.rightPanel === "notes"}
{#key accounts.selectedId}
<NotesPanel />
{/key}
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{:else if ui.rightPanel === "bots"}
+15 -16
View File
@@ -2,9 +2,11 @@
import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js";
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 { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
@@ -14,10 +16,11 @@
let { chatId }: Props = $props();
let busy = $state(false);
const started = createFlash<true>();
const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false);
const title = $derived(item?.title ?? "Этот чат");
const title = $derived(item?.title ?? t("track.thisChat"));
$effect(() => {
if (accounts.selectedId === null) {
@@ -33,10 +36,10 @@
busy = true;
try {
discover.set(await trackChat(chatId, true));
toasts.success("Отслеживание включено, история загружается");
started.show(true);
chats.refresh();
} catch {
toasts.error("Не удалось включить отслеживание");
toasts.error(t("track.failed"));
} finally {
busy = false;
}
@@ -49,9 +52,9 @@
busy = true;
try {
await enqueueBackfill(chatId, true, true);
toasts.success("Полный бэкфилл запущен");
started.show(true);
} catch {
toasts.error("Не удалось запустить бэкфилл");
toasts.error(t("jobs.backfillFailed"));
} finally {
busy = false;
}
@@ -59,26 +62,22 @@
</script>
<div class="track">
<div class="title">Здесь пока нет сообщений</div>
<div class="title">{t("track.empty")}</div>
<p class="description">
{#if tracked}
{title}
отслеживается — новые сообщения и статистика собираются автоматически.
{:else}
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
этому чату.
{/if}
{tracked ? t("track.tracked", { title }) : t("track.hint")}
</p>
<div class="actions">
{#if tracked}
<Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" />
<span>Загрузить историю</span>
<Icon name={started.current ? "check" : "cloud-download"} />
<span
>{started.current ? t("track.loading") : t("track.loadHistory")}</span
>
</Button>
{:else}
<Button loading={busy} onclick={track} pill variant="primary">
<Icon name="stats" />
<span>Начать отслеживать</span>
<span>{t("track.start")}</span>
</Button>
{/if}
</div>
@@ -16,12 +16,14 @@
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import {
alertAvatar,
alertHref,
alertLabel,
alertPreview,
alertTitle,
EVENT_ICONS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
@@ -48,15 +50,7 @@
);
function writeParam(value: Filter | null) {
const url = new URL(page.url.href);
if (value === null) {
url.searchParams.delete(ALERTS_PARAM);
} else {
url.searchParams.set(ALERTS_PARAM, value);
}
if (url.href !== page.url.href) {
goto(url, { replace: true, reset: false });
}
replaceParam(ALERTS_PARAM, value);
}
function setFilter(next: Filter) {
@@ -135,10 +129,10 @@
async function openAlert(alert: AlertItem) {
seen(alert);
await goto(alert.path);
if (isMobile()) {
ui.closePanel();
ui.resetPanel();
}
await goto(alertHref(alert));
}
async function seeAll() {
@@ -1,5 +1,6 @@
<script lang="ts">
import { untrack } from "svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import Button from "../ui/Button.svelte";
interface Props {
@@ -13,7 +14,7 @@
const {
initial = "",
saving = false,
submitLabel = "Добавить заметку",
submitLabel,
oncancel,
onsubmit,
}: Props = $props();
@@ -41,15 +42,19 @@
}}
>
<textarea
placeholder="Заметка к сообщению…"
placeholder={t("notes.placeholder")}
rows={oncancel ? 2 : 3}
bind:value={text}
></textarea>
<div class="actions">
{#if oncancel}
<Button onclick={oncancel} variant="translucent">Отмена</Button>
<Button onclick={oncancel} variant="translucent">
{t("common.cancel")}
</Button>
{/if}
<Button disabled={saving || !valid} type="submit">{submitLabel}</Button>
<Button disabled={saving || !valid} type="submit">
{submitLabel ?? t("notes.add")}
</Button>
</div>
</form>
@@ -1,8 +1,10 @@
<script lang="ts">
import type { Annotation } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte";
import NoteText from "./NoteText.svelte";
interface Props {
annotations: Annotation[];
@@ -28,14 +30,14 @@
onupdate(annotation, text);
editingId = null;
}}
submitLabel="Сохранить"
submitLabel={t("common.save")}
/>
{:else}
<p class="text">{annotation.text}</p>
<NoteText text={annotation.text} />
<div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span>
<button
aria-label="Изменить"
aria-label={t("notes.edit")}
class="action"
onclick={() => {
editingId = annotation.id;
@@ -45,7 +47,7 @@
<Icon name="edit" size="1rem" />
</button>
<button
aria-label="Удалить"
aria-label={t("common.delete")}
class="action"
onclick={() => ondelete(annotation)}
type="button"
@@ -74,12 +76,6 @@
border-radius: 0.75rem;
}
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.meta {
display: flex;
align-items: center;
@@ -9,7 +9,9 @@
import type { Annotation } from "#lib/api/types.js";
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 { notes } from "#lib/stores/notes.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state";
import AnnotationEditor from "./AnnotationEditor.svelte";
@@ -40,6 +42,7 @@
.then((rows) => {
if (current === token) {
items = rows;
notes.mark(cid, mid, rows.length > 0);
}
})
.finally(() => {
@@ -58,6 +61,7 @@
try {
const created = await createAnnotation(chatId, messageId, text);
items = [created, ...items];
notes.mark(chatId, messageId, true);
} finally {
saving = false;
}
@@ -71,11 +75,12 @@
async function remove(annotation: Annotation) {
await deleteAnnotation(annotation.id);
items = items.filter((item) => item.id !== annotation.id);
notes.mark(annotation.chat_id, annotation.message_id, items.length > 0);
}
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Заметки" />
<EmptyState description={t("ui.noMessage")} title={t("notes.title")} />
{:else}
<div class="panel-content">
<AnnotationEditor onsubmit={add} {saving} />
@@ -0,0 +1,84 @@
<script lang="ts">
import { noteParts, notes } from "#lib/stores/notes.svelte.js";
interface Props {
interactive?: boolean;
text: string;
}
const { interactive = true, text }: Props = $props();
const parts = $derived(noteParts(text));
function openTag(event: MouseEvent, tag: string) {
event.stopPropagation();
notes.openTag(tag);
}
</script>
<p class="note-text">
{#each parts as part, index (index)}
{#if "tag" in part && interactive}
<button
class="tag"
onclick={(event) => openTag(event, part.tag)}
type="button"
class:active={notes.tag === part.tag}
>
{part.label}
</button>
{:else if "tag" in part}
<span class="tag" class:active={notes.tag === part.tag}
>{part.label}</span
>
{:else}
{part.text}
{/if}
{/each}
</p>
<style lang="scss">
.note-text {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
display: inline;
padding: 0 0.3125rem;
border: 0;
border-radius: 0.375rem;
font: inherit;
font-size: 0.875em;
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
transition: background-color 0.15s ease;
&.active {
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (hover: hover) {
&:is(button):hover {
background-color: color-mix(
in srgb,
var(--color-primary) 20%,
transparent
);
}
}
&:is(button) {
cursor: pointer;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -0,0 +1,402 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { ripple } from "#lib/actions/ripple.js";
import {
listNotes,
listNoteTags,
type Note,
type NoteTag,
} from "#lib/api/notes.js";
import NoteText from "#lib/components/annotations/NoteText.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { NOTES_PARAM, notes } from "#lib/stores/notes.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 50;
let items = $state<Note[]>([]);
let tags = $state<NoteTag[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(false);
let failed = $state(false);
let token = 0;
const chips = $derived.by(() => {
const current = notes.tag;
if (current === null || tags.some((item) => item.tag === current)) {
return tags;
}
return [{ count: 0, tag: current }, ...tags];
});
async function load(tag: string | null, offset = 0) {
token += 1;
const mine = token;
if (offset === 0) {
loading = true;
} else {
loadingMore = true;
}
try {
const page = await listNotes(tag, offset, PAGE);
if (mine !== token) {
return;
}
items = offset === 0 ? page : [...items, ...page];
hasMore = page.length === PAGE;
failed = false;
} catch {
if (mine === token) {
failed = true;
}
} finally {
if (mine === token) {
loading = false;
loadingMore = false;
}
}
}
$effect(() => {
const { tag } = notes;
untrack(() => {
replaceParam(NOTES_PARAM, tag ?? "");
load(tag);
});
});
onMount(() => {
listNoteTags()
.then((result) => {
tags = result;
})
.catch(() => undefined);
return () => {
notes.tag = null;
replaceParam(NOTES_PARAM, null);
};
});
function pick(tag: string | null) {
notes.tag = notes.tag === tag ? null : tag;
}
function href(note: Note): string {
return `/app/${note.chat_id}?message=${note.message_id}`;
}
function open(event: MouseEvent, note: Note) {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.button !== 0
) {
return;
}
event.preventDefault();
ui.requestJump(note.chat_id, note.message_id);
if (isMobile()) {
ui.resetPanel();
goto(href(note));
return;
}
const tag = notes.tag === null ? "" : encodeURIComponent(notes.tag);
goto(`${href(note)}&${NOTES_PARAM}=${tag}`);
}
function chatTitle(note: Note): string {
return note.chat_title ?? t("chat.fallbackTitle", { id: note.chat_id });
}
function quote(note: Note): string {
if (note.message_date === null) {
return t("notes.messageGone");
}
return note.message_text ?? t("notes.mediaMessage");
}
</script>
<div class="notes">
{#if chips.length > 0}
<div class="chips">
<button
aria-pressed={notes.tag === null}
class="chip"
onclick={() => pick(null)}
type="button"
class:on={notes.tag === null}
>
{t("notes.all")}
</button>
{#each chips as item (item.tag)}
<button
aria-pressed={notes.tag === item.tag}
class="chip"
onclick={() => pick(item.tag)}
type="button"
class:on={notes.tag === item.tag}
>
#{item.tag}
{#if item.count > 0}
<span class="value">{item.count}</span>
{/if}
</button>
{/each}
</div>
{/if}
{#if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if failed && items.length === 0}
<div class="state">
<p>{t("notes.failed")}</p>
<Button onclick={() => load(notes.tag)} variant="text">
{t("notes.retry")}
</Button>
</div>
{:else if items.length === 0}
<EmptyState
description={notes.tag === null ? t("notes.empty") : undefined}
title={notes.tag === null
? t("notes.emptyTitle")
: t("notes.emptyTag", { tag: notes.tag })}
/>
{:else}
<ul class="list">
{#each items as note (note.id)}
<li>
<a
class="row"
href={href(note)}
onclick={(event) => open(event, note)}
use:ripple
>
<Avatar
avatar={{
id: note.chat_id,
kind: note.chat_id > 0 ? "peer" : "chat",
}}
colorKey={note.chat_id}
hasAvatar={note.chat_has_avatar}
name={chatTitle(note)}
size={2.75}
/>
<div class="body">
<div class="top">
<span class="title">{chatTitle(note)}</span>
<span class="date">{formatListDate(note.created_at)}</span>
</div>
<div class="text">
<NoteText interactive={false} text={note.text} />
</div>
<div class="quote" class:deleted={note.message_deleted}>
{quote(note)}
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if hasMore}
<div class="more">
<Button
loading={loadingMore}
onclick={() => load(notes.tag, items.length)}
variant="text"
>
{t("notes.more")}
</Button>
</div>
{/if}
{/if}
</div>
<style lang="scss">
.notes {
display: flex;
flex-direction: column;
min-height: 100%;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-borders);
}
.chip {
cursor: pointer;
display: inline-flex;
gap: 0.375rem;
align-items: center;
height: 1.75rem;
padding: 0 0.5625rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text);
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&.on {
background-color: color-mix(
in srgb,
var(--color-primary) 12%,
transparent
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem;
border-radius: 1.125rem;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.value {
color: var(--color-text-secondary);
}
.list {
margin: 0;
padding: 0.5rem;
list-style: none;
}
.row {
position: relative;
overflow: hidden;
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.5rem 0.625rem;
border-radius: 0.625rem;
color: var(--color-text);
text-decoration: none;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.body {
overflow: hidden;
flex: 1;
min-width: 0;
}
.top {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.title {
overflow: hidden;
flex: 1;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.text {
margin-top: 0.125rem;
font-size: 0.9375rem;
line-height: 1.35;
}
.quote {
overflow: hidden;
margin-top: 0.375rem;
padding-inline-start: 0.5rem;
border-inline-start: 2px solid var(--color-primary);
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.deleted {
text-decoration: line-through;
}
}
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.state {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 2rem 1rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
display: flex;
justify-content: center;
padding: 0 0.5rem 1rem;
}
</style>
@@ -8,6 +8,8 @@
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
@@ -17,14 +19,19 @@
let token = $state("");
let adding = $state(false);
const statusLabels: Record<string, string> = {
error: "ошибка",
ok: "слушает",
pending: "подключается",
removed: "отключён",
unauthorized: "токен отклонён",
const STATUS_KEYS: Record<string, MessageKey> = {
error: "bots.status.error",
ok: "bots.status.ok",
pending: "bots.status.pending",
removed: "bots.status.removed",
unauthorized: "bots.status.unauthorized",
};
function statusLabel(status: string): string {
const key = STATUS_KEYS[status];
return key ? t(key) : status;
}
async function load() {
loading = true;
try {
@@ -49,7 +56,7 @@
await load();
await accounts.load();
} catch {
toasts.error("Не похоже на токен бота");
toasts.error(t("bots.badToken"));
} finally {
adding = false;
}
@@ -81,7 +88,7 @@
{#if loading}
<Spinner />
{:else if bots.length === 0}
<p class="empty">Ботов пока нет.</p>
<p class="empty">{t("bots.empty")}</p>
{:else}
<ul class="bots">
{#each bots as bot (bot.bot_id)}
@@ -94,9 +101,9 @@
>
<span class="name">{botName(bot)}</span>
<span class="meta">
{statusLabels[bot.status] ?? bot.status}
{statusLabel(bot.status)}
{#if bot.account_id !== null}
· аккаунт {bot.account_id}
· {t("bots.account", { id: bot.account_id })}
{/if}
</span>
{#if bot.status_detail}
@@ -105,7 +112,7 @@
</button>
{#if bot.enabled}
<Button
aria-label="Отключить"
aria-label={t("bots.disconnect")}
onclick={() => remove(bot)}
round
smaller
@@ -121,25 +128,14 @@
<div class="add">
<label class="field">
<span>Токен бота от @BotFather</span>
<span>{t("bots.token")}</span>
<input placeholder="123456:AA…" type="password" bind:value={token}>
</label>
<Button fluid loading={adding} onclick={add}>Добавить</Button>
<Button fluid loading={adding} onclick={add}>{t("bots.add")}</Button>
</div>
<p class="note">
Бот входит по MTProto отдельной авторизацией — это не отнимает getUpdates у
того, кто держит того же бота через Bot API, и не требует вебхука. Дальше он
становится обычным аккаунтом архива: чаты, сообщения, медиа и участники
групп видны там же, где переписка остальных аккаунтов.
</p>
<p class="note">
Прошлое подтягивается из очереди недоставленных апдейтов, которую Telegram
держит для бота; история диалогов боту запрещена, глубже этой очереди
заглянуть нельзя. В личке бот видит каждого, кто ему написал; в группах всё
— только при выключенном privacy mode. Отключение убирает токен и сессию,
архив остаётся.
</p>
<p class="note">{t("bots.login")}</p>
<p class="note">{t("bots.history")}</p>
</div>
<style lang="scss">
@@ -13,8 +13,9 @@
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
interface Props {
label?: string;
@@ -28,12 +29,12 @@
let confirming = $state(false);
let moving = $state(false);
let moved = $state<TrashOutcome | null>(null);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
function where(scope: StorageScope): string {
return "folderId" in target
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
: "в этом чате";
? t("storage.inFolder", { count: scope.chats })
: t("storage.inChat");
}
async function load() {
@@ -56,7 +57,7 @@
await load();
onchange?.();
} catch {
problem = "Не удалось переместить файлы в корзину";
problem = "storage.scope.trashFailed";
} finally {
moving = false;
}
@@ -74,7 +75,7 @@
await load();
onchange?.();
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
} finally {
moving = false;
}
@@ -90,15 +91,15 @@
{#if moved && moved.files > 0}
<div class="files">
<div class="text">
<span>{filesLabel(moved.files)} в корзине</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
<span>{t("storage.inTrash", { files: filesLabel(moved.files) })}</span>
<small>{t("storage.freed", { size: formatBytes(moved.bytes) })}</small>
{#if problem}
<small class="failed">{problem}</small>
<small class="failed">{t(problem)}</small>
{/if}
</div>
<Button disabled={moving} onclick={undo} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
<span>{t("storage.restore")}</span>
</Button>
</div>
{:else if files && files.files > 0}
@@ -106,22 +107,19 @@
{#if confirming}
<div class="text">
<span>
{filesLabel(files.files)}, {formatBytes(files.bytes)},
{plural(
files.files,
"уйдёт в корзину и перестанет скачиваться сам",
"уйдут в корзину и перестанут скачиваться сами",
"уйдут в корзину и перестанут скачиваться сами"
)}.
{t("storage.scope.confirm", {
count: files.files,
files: filesLabel(files.files),
size: formatBytes(files.bytes),
})}
</span>
{#if files.gone > 0}
<small class="warn">
{files.gone}
из них потом не скачать заново
{t("storage.scope.gone", { count: files.gone })}
</small>
{/if}
{#if problem}
<small class="failed">{problem}</small>
<small class="failed">{t(problem)}</small>
{/if}
<div class="actions">
<Button
@@ -131,7 +129,7 @@
smaller
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
<Button
disabled={moving}
@@ -141,7 +139,7 @@
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
<span>{t("storage.toTrash")}</span>
</Button>
</div>
</div>
@@ -156,18 +154,18 @@
</small>
{/if}
{:else}
<span>{formatBytes(files.bytes)} уже скачано</span>
<span>
{t("storage.scope.downloaded", { size: formatBytes(files.bytes) })}
</span>
<small>{filesLabel(files.files)} {where(files)}</small>
{/if}
{#if files.kept > 0}
<small>
Ещё {filesLabel(files.kept)} есть и в других чатах,
{plural(
files.kept,
"он останется",
"они останутся",
"они останутся"
)}
{t("storage.scope.kept", {
count: files.kept,
files: filesLabel(files.kept),
n: files.kept.toLocaleString(locale.tag),
})}
</small>
{/if}
</div>
@@ -180,7 +178,7 @@
variant="gray"
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
<span>{t("storage.toTrash")}</span>
</Button>
{/if}
</div>
@@ -189,10 +187,10 @@
<div class="text">
<span>{label}</span>
<small>
{filesLabel(files.kept)}
есть и в других чатах, убрать
{plural(files.kept, "его", "их", "их")}
можно из списка ниже
{t("storage.scope.keptOnly", {
count: files.kept,
files: filesLabel(files.kept),
})}
</small>
</div>
</div>
@@ -4,7 +4,7 @@
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import { formatBytes } from "#lib/format/media.js";
import { filesLabel, GROUP_LABELS } from "#lib/format/storage.js";
import { filesLabel, groupLabel } from "#lib/format/storage.js";
interface Props {
chat: StorageChat;
@@ -24,7 +24,7 @@
const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
const makeup = $derived(
parts
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`)
.map((item) => `${groupLabel(item.group)}: ${formatBytes(item.bytes)}`)
.join(", ")
);
</script>
@@ -31,9 +31,11 @@
import { formatBytes } from "#lib/format/media.js";
import {
filesLabel,
GROUP_LABELS,
groupLabel,
messagesLabel,
} from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import {
@@ -53,9 +55,9 @@
const PAGE = 30;
const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600;
const LISTINGS: { label: string; value: StorageListing }[] = [
{ label: "Файлы", value: "files" },
{ label: "Чаты", value: "chats" },
const LISTINGS: { label: MessageKey; value: StorageListing }[] = [
{ label: "storage.listing.files", value: "files" },
{ label: "storage.listing.chats", value: "chats" },
];
let summary = $state<StorageSummary | null>(null);
@@ -70,7 +72,7 @@
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null
);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<number>();
@@ -89,12 +91,11 @@
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
const emptiness = $derived.by(() => {
if (group !== null) {
return "В этой группе нет файлов.";
return t("storage.empty.group");
}
return chatId === null
? "На диске нет скачанных файлов."
: "В этом чате нет скачанных файлов.";
return chatId === null ? t("storage.empty.all") : t("storage.empty.chat");
});
const failure = $derived(problem && t(problem));
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0)
@@ -120,7 +121,7 @@
);
function chatTitle(id: number): string {
return chats.byId(id)?.title ?? `Чат ${id}`;
return chats.byId(id)?.title ?? t("ui.chat", { id });
}
async function loadSummary() {
@@ -249,11 +250,11 @@
scope?.refresh();
showUndo(outcome, trashed);
if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске";
problem = "storage.blocked";
}
await loadSummary();
} catch {
problem = "Не удалось переместить в корзину";
problem = "storage.trashFailed";
} finally {
busy = false;
}
@@ -273,7 +274,7 @@
scope?.refresh();
await loadSummary();
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
}
}
@@ -281,7 +282,7 @@
try {
await downloadMedia(file.media_id);
} catch {
problem = "Не удалось скачать файл";
problem = "ui.downloadFailed";
}
}
@@ -330,22 +331,26 @@
{#if summary.files > 0 || chatId !== null}
<section class="summary">
<div class="hero">
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
<span class="total">
{formatBytes(summary.bytes) || t("storage.zero")}
</span>
<span class="count">
{filesLabel(summary.files)}
{#if chatId !== null}
в этом чате
{t("storage.hero.chat", { files: filesLabel(summary.files) })}
{:else if summary.trash_bytes > 0}
· ещё {formatBytes(summary.trash_bytes)} в корзине
{t("storage.hero.trash", {
files: filesLabel(summary.files),
size: formatBytes(summary.trash_bytes),
})}
{:else}
на диске
{t("storage.hero.disk", { files: filesLabel(summary.files) })}
{/if}
</span>
</div>
<div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)}
<span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
title="{groupLabel(item.group)}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})"
style:flex-grow={item.bytes}
></span>
@@ -353,7 +358,9 @@
{#if chatId === null && summary.trash_bytes > 0}
<span
class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}"
title={t("storage.trash.title", {
size: formatBytes(summary.trash_bytes),
})}
style:flex-grow={summary.trash_bytes}
></span>
{/if}
@@ -371,7 +378,7 @@
class="dot"
style:background-color="var(--color-storage-{item.group})"
></span>
{GROUP_LABELS[item.group]}
{groupLabel(item.group)}
<span class="value">{formatBytes(item.bytes)}</span>
</button>
{/each}
@@ -386,14 +393,14 @@
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span>
<span class="label">{t("storage.trash")}</span>
<span class="value">
{#if summary.trash_files > 0}
<span class="swatch"></span>
{filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)}
{:else}
пусто
{t("storage.trash.nothing")}
{/if}
</span>
<Icon name="next" size="1.125rem" />
@@ -407,13 +414,13 @@
type="button"
class:on={listing === item.value}
>
{item.label}
{t(item.label)}
</button>
{/each}
</div>
{:else}
<ScopeFiles
label="Все файлы чата"
label={t("storage.chatFiles")}
onchange={() => {
hideUndo();
reload();
@@ -426,12 +433,14 @@
{#if listing === "files" && files.length > 0}
<div class="section-title">
<h3>Самые тяжёлые</h3>
<h3>{t("storage.heaviest")}</h3>
{#if summary && group === null && summary.heavy_files > 0}
<span>
{filesLabel(summary.heavy_files)}
крупнее {formatBytes(summary.heavy_threshold)}
= {formatBytes(summary.heavy_bytes)}
{t("storage.heavy", {
files: filesLabel(summary.heavy_files),
size: formatBytes(summary.heavy_bytes),
threshold: formatBytes(summary.heavy_threshold),
})}
</span>
{/if}
</div>
@@ -448,17 +457,17 @@
>
{#snippet menu()}
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
Файлы чата
{t("storage.menu.chatFiles")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
Перейти в чат
{t("storage.menu.openChat")}
</ContextMenuItem>
<ContextMenuItem
icon="settings"
onselect={() =>
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
>
Политика захвата
{t("storage.menu.policy")}
</ContextMenuItem>
{/snippet}
</StorageChatRow>
@@ -484,19 +493,19 @@
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
{t("ui.open")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
Перейти к сообщению
{t("ui.jumpToMessage")}
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}>
Скачать файл
{t("ui.downloadFile")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => trash([file.media_id])}
>
В корзину
{t("storage.toTrash")}
</ContextMenuItem>
{/snippet}
</StorageRow>
@@ -506,29 +515,31 @@
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить хранилище.</p>
<p>{t("storage.loadFailed")}</p>
<Button onclick={() => reload()} smaller variant="text">
Повторить
{t("ui.retry")}
</Button>
</div>
{:else if shown === 0}
<div class="state"><p>{emptiness}</p></div>
{:else if !done}
<button class="more" onclick={() => loadMore()} type="button">
Показать ещё
{t("ui.showMore")}
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
{failure}
note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn
>
<Button onclick={() => selected.clear()} smaller variant="text">
Снять
{t("storage.deselect")}
</Button>
<Button
disabled={busy}
@@ -538,22 +549,22 @@
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
<span>{t("storage.toTrash")}</span>
</Button>
</SelectionBar>
{:else if undo}
<SelectionBar
failure={problem}
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней"
title="{filesLabel(undo.outcome.files)} в корзине"
{failure}
note={t("storage.freed", { size: formatBytes(undo.outcome.bytes) })}
title={t("storage.inTrash", { files: filesLabel(undo.outcome.files) })}
>
<Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
<span>{t("storage.restore")}</span>
</Button>
</SelectionBar>
{:else if problem}
<SelectionBar failure={problem} title="Не получилось">
{:else if failure}
<SelectionBar {failure} title={t("storage.failed")}>
<Button
onclick={() => {
problem = null;
@@ -561,7 +572,7 @@
smaller
variant="text"
>
Понятно
{t("ui.gotIt")}
</Button>
</SelectionBar>
{/if}
@@ -5,6 +5,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { groupOf } from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
fileName: string;
@@ -54,7 +55,7 @@
>
<span class="check">
<input
aria-label="Выбрать {fileName}"
aria-label={t("storage.select", { name: fileName })}
checked={selected}
onchange={ontoggle}
type="checkbox"
@@ -82,7 +83,7 @@
<span class="note">{note}</span>
{/if}
{#if !refetchable}
<span class="gone">Не скачать заново</span>
<span class="gone">{t("storage.gone")}</span>
{/if}
</span>
<span class="size">{formatBytes(size)}</span>
@@ -19,12 +19,13 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import {
filesLabel,
messagesLabel,
purgeLabel,
} from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
@@ -40,7 +41,7 @@
let failed = $state(false);
let busy = $state(false);
let doomed = $state<TrashItem[] | null>(null);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<string>();
@@ -58,6 +59,7 @@
const doomedGone = $derived(
doomed?.filter((item) => !item.refetchable).length ?? 0
);
const failure = $derived(problem && t(problem));
const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
@@ -77,7 +79,7 @@
}
function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
return chats.byId(chatId)?.title ?? t("ui.chat", { id: chatId });
}
async function loadTotal() {
@@ -132,7 +134,7 @@
await restoreFromTrash(keys);
drop(keys);
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
} finally {
busy = false;
}
@@ -148,7 +150,7 @@
await restoreFromTrash(null);
await load();
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
} finally {
busy = false;
}
@@ -167,7 +169,7 @@
everything = false;
await load();
} catch {
problem = "Не удалось удалить файлы";
problem = "storage.trash.deleteFailed";
} finally {
busy = false;
}
@@ -190,15 +192,12 @@
</script>
<div class="trash">
<p class="note">
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
вернуть.
</p>
<p class="note">{t("storage.trash.note")}</p>
{#if total.files > 0}
<button class="act" disabled={busy} onclick={restoreAll} type="button">
<Icon name="undo" size="1.375rem" />
<span class="label">Вернуть всё</span>
<span class="label">{t("storage.trash.restoreAll")}</span>
<span class="value">{filesLabel(total.files)}</span>
</button>
<button
@@ -209,7 +208,7 @@
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span>
<span class="label">{t("storage.trash.clear")}</span>
<span class="value">{formatBytes(total.bytes)}</span>
</button>
{/if}
@@ -233,13 +232,13 @@
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
{t("ui.open")}
</ContextMenuItem>
<ContextMenuItem icon="undo" onselect={() => restore([item])}>
Вернуть
{t("storage.restore")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(item)}>
Перейти к сообщению
{t("ui.jumpToMessage")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
@@ -247,7 +246,7 @@
doomed = [item];
}}
>
Удалить насовсем
{t("storage.trash.deleteForever")}
</ContextMenuItem>
{/snippet}
</StorageRow>
@@ -257,22 +256,24 @@
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить корзину.</p>
<Button onclick={load} smaller variant="text">Повторить</Button>
<p>{t("storage.trash.loadFailed")}</p>
<Button onclick={load} smaller variant="text">{t("ui.retry")}</Button>
</div>
{:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div>
<div class="state"><p>{t("storage.trash.empty")}</p></div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
{t("ui.showMore")}
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={dialogOpen ? null : problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
failure={dialogOpen ? null : failure}
note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn
>
@@ -284,7 +285,7 @@
smaller
variant="danger"
>
Удалить
{t("common.delete")}
</Button>
<Button
disabled={busy}
@@ -294,11 +295,11 @@
smaller
>
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
<span>{t("storage.restore")}</span>
</Button>
</SelectionBar>
{:else if problem && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось">
{:else if failure && !dialogOpen}
<SelectionBar {failure} title={t("storage.failed")}>
<Button
onclick={() => {
problem = null;
@@ -306,7 +307,7 @@
smaller
variant="text"
>
Понятно
{t("ui.gotIt")}
</Button>
</SelectionBar>
{/if}
@@ -327,59 +328,53 @@
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
{everything
? t("storage.trash.clearTitle")
: t("storage.trash.deleteTitle")}
</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if everything}
<p class="confirm">
{filesLabel(total.files)}, {formatBytes(total.bytes)},
{plural(
total.files,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
</p>
<p class="confirm rest">
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
удалённых там сообщений пропадут насовсем.
{t("storage.trash.willDelete", {
count: total.files,
files: filesLabel(total.files),
size: formatBytes(total.bytes),
})}
</p>
<p class="confirm rest">{t("storage.trash.clearRest")}</p>
{:else if doomed}
<p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
{plural(
doomed.length,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
{t("storage.trash.willDelete", {
count: doomed.length,
files: filesLabel(doomed.length),
size: formatBytes(bytesOf(doomed)),
})}
</p>
{#if doomedGone > 0}
<p class="confirm warn">
<Icon name="warning" size="1.25rem" />
<span>
{filesLabel(doomedGone)}
скачать заново нельзя: в Telegram
{doomedGone === 1 ? "его" : "их"}
уже нет.
{t("storage.trash.goneWarn", {
count: doomedGone,
files: filesLabel(doomedGone),
})}
</span>
</p>
{/if}
{#if doomedGone < doomed.length}
<p class="confirm rest">
{doomedGone === 0 ? "Потом их" : "Остальные"}
можно скачать из Telegram заново.
{doomedGone === 0
? t("storage.trash.laterAll", { count: doomed.length })
: t("storage.trash.laterRest")}
</p>
{/if}
{/if}
{#if problem}
<p class="confirm failed">{problem}.</p>
{#if failure}
<p class="confirm failed">{failure}.</p>
{/if}
</div>
<div class="dialog-actions">
@@ -387,7 +382,9 @@
{#snippet child({
props,
})}
<Button {...props} pill variant="text">Отмена</Button>
<Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet}
</Dialog.Close>
<Button
@@ -397,7 +394,7 @@
pill
variant="danger"
>
Удалить
{t("common.delete")}
</Button>
</div>
</Dialog.Content>
+43 -29
View File
@@ -3,7 +3,10 @@
import type { JobStatus, JobView } from "#lib/api/types.js";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
@@ -14,25 +17,22 @@
const POLL_MS = 2000;
const KIND_LABELS: Record<string, string> = {
backfill: "Бэкфилл",
backfill_stories: "Бэкфилл сторис",
enrich_chat: "Обогащение чата",
fetch_avatar: "Аватар",
fetch_custom_emoji: "Кастом-эмодзи",
fetch_media: "Докачка медиа",
sync_dialogs: "Синхронизация диалогов",
transcribe: "Расшифровка",
const KIND_KEYS: Record<string, MessageKey> = {
backfill: "jobs.kind.backfill",
backfill_stories: "jobs.kind.backfill_stories",
enrich_chat: "jobs.kind.enrich_chat",
fetch_avatar: "jobs.kind.fetch_avatar",
fetch_custom_emoji: "jobs.kind.fetch_custom_emoji",
fetch_media: "jobs.kind.fetch_media",
fetch_read_info: "jobs.kind.fetch_read_info",
sync_contacts: "jobs.kind.sync_contacts",
sync_dialogs: "jobs.kind.sync_dialogs",
transcribe: "jobs.kind.transcribe",
};
const STATUS_LABELS: Record<JobStatus, string> = {
canceled: "Отменено",
done: "Готово",
failed: "Ошибка",
paused: "Пауза",
pending: "В очереди",
running: "Выполняется",
};
function statusLabel(status: JobStatus): string {
return t(`jobs.status.${status}`);
}
let jobs = $state<JobView[]>([]);
let loading = $state(true);
@@ -56,7 +56,7 @@
await cancelJob(job.id);
await load();
} catch {
toasts.error("Не удалось остановить задачу");
toasts.error(t("jobs.stopFailed"));
} finally {
canceling = null;
}
@@ -76,16 +76,22 @@
async function load() {
jobs = await listJobs();
chats.ensureMany(
jobs.map(chatId).filter((id): id is number => id !== null)
);
loading = false;
schedule();
}
function kindLabel(job: JobView): string {
const label = KIND_LABELS[job.kind] ?? job.kind;
const key = KIND_KEYS[job.kind];
const kind = key ? t(key) : job.kind;
if (job.kind !== "backfill") {
return label;
return kind;
}
return job.params.full ? `${label} (полный)` : `${label} (новые)`;
return job.params.full
? t("jobs.kind.full", { kind })
: t("jobs.kind.newer", { kind });
}
function processed(job: JobView): number | null {
@@ -98,6 +104,10 @@
return typeof value === "number" ? value : null;
}
function chatLabel(id: number): string {
return chats.byId(id)?.title ?? t("jobs.chat", { id });
}
$effect(() => {
if (version >= 0 && accounts.selectedId !== null) {
load().catch(() => {
@@ -116,10 +126,12 @@
{#if loading}
<div class="state"><Spinner /></div>
{:else if jobs.length === 0}
<div class="state empty">Задач нет</div>
<div class="state empty">{t("jobs.empty")}</div>
{:else}
<div class="jobs">
{#each jobs as job (job.id)}
{@const chat = chatId(job)}
{@const count = processed(job)}
<div class="job">
<div class="job-head">
<span class="kind">{kindLabel(job)}</span>
@@ -130,20 +142,22 @@
onclick={() => cancel(job)}
type="button"
>
Стоп
{t("jobs.stop")}
</button>
{/if}
<span class="badge {job.status}">{STATUS_LABELS[job.status]}</span>
<span class="badge {job.status}">{statusLabel(job.status)}</span>
</div>
<div class="meta">
{#if chatId(job) !== null}
<span>чат {chatId(job)}</span>
{#if chat !== null}
<span>{chatLabel(chat)}</span>
{/if}
{#if processed(job) !== null}
<span>обработано {processed(job)}</span>
{#if count !== null}
<span>
{t("jobs.processed", { count: count.toLocaleString(locale.tag) })}
</span>
{/if}
{#if job.flood_waits > 0}
<span>flood-wait {job.flood_waits}</span>
<span>{t("jobs.floodWaits", { count: job.flood_waits })}</span>
{/if}
<span class="time">{formatFull(job.created_at)}</span>
</div>
@@ -8,8 +8,10 @@
import JobList from "#lib/components/jobs/JobList.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 { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { page } from "$app/state";
@@ -23,6 +25,7 @@
let starting = $state(false);
let syncing = $state(false);
let syncingContacts = $state(false);
const started = createFlash<"backfill" | "sync" | "contacts">();
const picker = createChatPicker();
const availableChats = $derived(picker.results);
@@ -39,9 +42,9 @@
function chatTitle(id: number | null): string {
if (id === null) {
return "Чат не выбран";
return t("jobs.noChat");
}
return chats.byId(id)?.title ?? `Чат ${id}`;
return chats.byId(id)?.title ?? t("ui.chat", { id });
}
function selectChat(id: number) {
@@ -57,10 +60,10 @@
starting = true;
try {
await enqueueBackfill(selected, media, true);
toasts.success("Полный бэкфилл запущен");
started.show("backfill");
version += 1;
} catch {
toasts.error("Не удалось запустить бэкфилл");
toasts.error(t("jobs.backfillFailed"));
} finally {
starting = false;
}
@@ -73,10 +76,10 @@
syncing = true;
try {
await syncDialogs();
toasts.success("Синхронизация диалогов запущена");
started.show("sync");
version += 1;
} catch {
toasts.error("Не удалось синхронизировать диалоги");
toasts.error(t("jobs.syncFailed"));
} finally {
syncing = false;
}
@@ -89,10 +92,10 @@
syncingContacts = true;
try {
await syncContacts();
toasts.success("Синхронизация контактов запущена");
started.show("contacts");
version += 1;
} catch {
toasts.error("Не удалось синхронизировать контакты");
toasts.error(t("jobs.contactsFailed"));
} finally {
syncingContacts = false;
}
@@ -105,21 +108,20 @@
<div class="panel">
<section>
<div class="section-title">Диалоги</div>
<p class="hint">
Синхронизация подтягивает все диалоги аккаунта — старые чаты станут видны
и доступны для бэкфилла.
</p>
<div class="section-title">{t("jobs.dialogs")}</div>
<p class="hint">{t("jobs.dialogsHint")}</p>
<div class="action">
<Button fluid loading={syncing} onclick={sync} variant="secondary">
<Icon name="reload" />
<span>Синхронизировать диалоги</span>
{#if started.current === "sync"}
<Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="reload" />
<span>{t("jobs.syncDialogs")}</span>
{/if}
</Button>
</div>
<p class="hint">
Контакты подтягиваются отдельно — тогда в поиске появятся люди, с которыми
ещё не было переписки.
</p>
<p class="hint">{t("jobs.contactsHint")}</p>
<div class="action">
<Button
fluid
@@ -127,18 +129,20 @@
onclick={syncContactList}
variant="secondary"
>
<Icon name="user" />
<span>Синхронизировать контакты</span>
{#if started.current === "contacts"}
<Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="user" />
<span>{t("jobs.syncContacts")}</span>
{/if}
</Button>
</div>
</section>
<section>
<div class="section-title">Бэкфилл</div>
<p class="hint">
Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в
шапке чата догружает только сообщения новее последнего сохранённого.
</p>
<div class="section-title">{t("jobs.backfill")}</div>
<p class="hint">{t("jobs.backfillHint")}</p>
<button
class="select"
@@ -155,8 +159,9 @@
{#if picking}
<div class="picker">
<input
aria-label={t("ui.searchChat")}
class="filter"
placeholder="Поиск чата"
placeholder={t("ui.searchChat")}
type="text"
bind:value={filter}
>
@@ -166,7 +171,7 @@
onclick={() => selectChat(chat.chat_id)}
type="button"
>
{chat.title ?? `Чат ${chat.chat_id}`}
{chat.title ?? t("ui.chat", { id: chat.chat_id })}
</button>
{/each}
</div>
@@ -181,7 +186,7 @@
role="switch"
type="button"
>
<span class="label">Скачивать медиа</span>
<span class="label">{t("jobs.downloadMedia")}</span>
<span class="switch" class:on={media}>
<span class="knob"></span>
</span>
@@ -195,14 +200,19 @@
onclick={start}
variant="primary"
>
<Icon name="cloud-download" />
<span>Запустить полный бэкфилл</span>
{#if started.current === "backfill"}
<Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="cloud-download" />
<span>{t("jobs.startBackfill")}</span>
{/if}
</Button>
</div>
</section>
<section>
<div class="section-title">Задачи</div>
<div class="section-title">{t("jobs.title")}</div>
<JobList {version} />
</section>
</div>
@@ -9,7 +9,9 @@
import type { MediaRef } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
@@ -23,7 +25,17 @@
let loaded = $state(false);
let inline = $state<InlineMedia | null>(null);
let queuing = $state(false);
const queued = createFlash<true>();
const queueIcon = $derived.by(() => {
if (queuing) {
return "timer";
}
return queued.current ? "check" : "download";
});
const queueLabel = $derived(
queued.current ? t("chat.media.queued") : t("media.downloadToArchive")
);
const ready = $derived(inline?.state === "ready" ? inline : null);
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
@@ -39,9 +51,9 @@
queuing = true;
try {
await fetchMedia(chatId, media.message_id);
toasts.success("Download queued");
queued.show(true);
} catch {
toasts.error("Failed to queue download");
toasts.error(t("media.queueFailed"));
} finally {
queuing = false;
}
@@ -62,11 +74,17 @@
</button>
{:else if ready}
<button class="tile" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}>
<img alt={t("media.attachment")} src={ready.url}>
</button>
{:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<button
aria-label={queueLabel}
class="tile placeholder"
onclick={queue}
title={queueLabel}
type="button"
>
<Icon name={queueIcon} size="1.5rem" />
</button>
{:else if loaded}
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
interface Props {
@@ -41,7 +42,12 @@
</script>
<div class="Audio" class:own>
<button aria-label="Play audio" class="toggle" onclick={toggle} type="button">
<button
aria-label={paused ? t("ui.play") : t("ui.pause")}
class="toggle"
onclick={toggle}
type="button"
>
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
</button>
<div class="content">
@@ -3,6 +3,7 @@
import type { MediaRef } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -16,7 +17,7 @@
let saving = $state(false);
const name = $derived(
media.file_name ?? mediaKindLabel(media.kind) ?? "Файл"
media.file_name ?? mediaKindLabel(media.kind) ?? t("media.kind.document")
);
const size = $derived(formatBytes(media.file_size));
@@ -28,7 +29,7 @@
try {
await downloadMedia(media.id);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("ui.downloadFailed"));
} finally {
saving = false;
}
@@ -52,7 +53,7 @@
</span>
</button>
<button
aria-label="Доступ по ссылке"
aria-label={t("ui.shareLink")}
class="file-share"
onclick={share}
type="button"
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { POSTER_TIME, poster } from "#lib/media/poster.js";
@@ -38,7 +39,7 @@
<div class="RoundVideoWrap">
<button
aria-label="Play video message"
aria-label={paused ? t("media.playRound") : t("ui.pause")}
class="RoundVideo"
onclick={toggle}
type="button"
@@ -87,7 +88,7 @@
class:active={showTranscript}
>
<Icon name="transcribe" size="1rem" />
Transcription
{t("media.transcription")}
</button>
{#if showTranscript}
<div class="transcript">{transcript}</div>
@@ -3,6 +3,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -38,10 +39,10 @@
fetched = result;
showTranscript = true;
} else {
toasts.error("Не удалось расшифровать");
toasts.error(t("media.transcribeFailed"));
}
} catch {
toasts.error("Не удалось расшифровать");
toasts.error(t("media.transcribeFailed"));
} finally {
pending = false;
}
@@ -96,7 +97,7 @@
<div class="VoiceWrap" class:own>
<div class="Voice">
<button
aria-label="Play voice"
aria-label={paused ? t("media.playVoice") : t("ui.pause")}
class="toggle"
onclick={toggle}
type="button"
@@ -121,7 +122,9 @@
<div class="time">{formatDuration(elapsed)}</div>
</div>
<button
aria-label="Show transcription"
aria-label={showTranscript && text
? t("media.hideTranscription")
: t("media.showTranscription")}
class="transcribe"
disabled={pending}
onclick={onTranscribe}
@@ -24,9 +24,11 @@
sameToggles,
summary,
TOGGLE_KEYS,
TOGGLE_LABELS,
type ToggleKey,
toggleLabel,
} from "#lib/format/policy.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
interface Props {
onchange?: () => void;
@@ -44,17 +46,17 @@
onchange,
}: Props = $props();
const PRESETS: { label: string; toggles: CaptureToggles }[] = [
{ label: "Всё", toggles: preset("all") },
{ label: "Только сообщения", toggles: preset(["messages"]) },
{ label: "Ничего", toggles: preset([]) },
const PRESETS: { label: MessageKey; toggles: CaptureToggles }[] = [
{ label: "policy.summary.all", toggles: preset("all") },
{ label: "policy.summary.messagesOnly", toggles: preset(["messages"]) },
{ label: "policy.summary.none", toggles: preset([]) },
];
let rule = $state<PolicyRecord | null>(null);
let parent = $state<PolicyParent | null>(null);
let loading = $state(false);
let saving = $state(false);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let removing = $state(false);
const isChat = $derived(scopeType === "chat");
@@ -79,13 +81,14 @@
if (isChat && parent) {
const from = parentLabel(parent.scope_type, parent.title);
return rule
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.`
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`;
? t("policy.hint.chatRule", {
from,
summary: summary(parent.toggles).toLocaleLowerCase(locale.tag),
})
: t("policy.hint.chatInherit", { from });
}
if (isFolder) {
return rule
? "Действует на чаты папки, у которых нет своего правила."
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
return rule ? t("policy.hint.folderRule") : t("policy.hint.folderNone");
}
return "";
}
@@ -112,7 +115,7 @@
});
onchange?.();
} catch {
problem = "Не удалось сохранить правило";
problem = "policy.rule.saveFailed";
} finally {
saving = false;
}
@@ -136,7 +139,7 @@
removing = false;
onchange?.();
} catch {
problem = "Не удалось удалить правило";
problem = "policy.rule.deleteFailed";
} finally {
saving = false;
}
@@ -153,7 +156,7 @@
try {
({ rule, parent } = await getPolicyScope(target));
} catch {
problem = "Не удалось загрузить правило";
problem = "policy.rule.loadFailed";
} finally {
loading = false;
}
@@ -179,7 +182,7 @@
type="button"
class:on={shown !== null && sameToggles(shown, item.toggles)}
>
{item.label}
{t(item.label)}
</button>
{/each}
</div>
@@ -196,7 +199,7 @@
type="button"
class:changed={differs(key)}
>
<span class="label">{TOGGLE_LABELS[key]}</span>
<span class="label">{toggleLabel(key)}</span>
<span class="switch" class:on={shown[key]}>
<span class="knob"></span>
</span>
@@ -206,7 +209,7 @@
{/if}
{#if problem}
<p class="problem">{problem}</p>
<p class="problem">{t(problem)}</p>
{/if}
{#if target}
@@ -216,7 +219,7 @@
{#if rule && (isChat || isFolder)}
{#if removing}
<div class="removal">
<span>Удалить правило?</span>
<span>{t("policy.rule.removeConfirm")}</span>
<Button
onclick={() => {
removing = false;
@@ -224,7 +227,7 @@
smaller
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
<Button
disabled={saving}
@@ -233,7 +236,7 @@
smaller
variant="danger"
>
Удалить
{t("common.delete")}
</Button>
</div>
{:else}
@@ -244,7 +247,7 @@
}}
type="button"
>
Удалить правило
{t("policy.rule.remove")}
</button>
{/if}
{/if}
@@ -2,6 +2,7 @@
import { Dialog } from "bits-ui";
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
const target = $derived(policyUi.target);
@@ -22,10 +23,10 @@
<div class="heading">
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
<span class="kind">
{isChat ? "Политика захвата чата" : "Политика захвата папки"}
{isChat ? t("policy.kind.chat") : t("policy.kind.folder")}
</span>
</div>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
@@ -14,11 +14,13 @@
import {
type Chip,
contentChips,
DEFAULT_LABELS,
defaultLabel,
diffChips,
sameToggles,
summary,
} from "#lib/format/policy.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
@@ -43,7 +45,7 @@
let folders = $state<Folder[]>([]);
let loading = $state(true);
let failed = $state(false);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let expanded = $state<string | null>(null);
let draft = $state<Draft | null>(null);
let picking = $state<"folder" | "chat" | null>(null);
@@ -64,7 +66,7 @@
const chatRules = $derived(
rules
.filter((rule) => rule.scope_type === "chat")
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
.toSorted((a, b) => title(a).localeCompare(title(b), locale.tag))
);
const freeFolders = $derived(
folders.filter(
@@ -88,12 +90,12 @@
function title(rule: PolicyRule): string {
if (rule.scope_type === "chat") {
return rule.title ?? `Чат ${rule.scope_id}`;
return rule.title ?? t("ui.chat", { id: rule.scope_id ?? "" });
}
if (rule.scope_type === "folder") {
return rule.title ?? `Папка ${rule.scope_id}`;
return rule.title ?? t("policy.folder", { id: rule.scope_id ?? "" });
}
return DEFAULT_LABELS[rule.scope_type] ?? "";
return defaultLabel(rule.scope_type);
}
function redundant(rule: PolicyRule): boolean {
@@ -164,7 +166,7 @@
await deletePolicy(rule.id);
await reload();
} catch {
problem = "Не удалось убрать правило";
problem = "policy.rule.dropFailed";
}
}
@@ -227,7 +229,7 @@
{#if plain}
<span class="sub">{summary(rule)}</span>
{:else if extra}
<span class="sub">Ничего не меняет</span>
<span class="sub">{t("policy.rule.redundant")}</span>
{:else}
<span class="chips">
{#each chips(rule) as chip (chip.sign + chip.label)}
@@ -242,7 +244,7 @@
</button>
{#if extra}
<Button onclick={() => drop(rule)} smaller variant="text">
Убрать
{t("policy.rule.drop")}
</Button>
{/if}
<button
@@ -291,7 +293,7 @@
{/if}
<span class="text">
<span class="name">{item.title}</span>
<span class="sub">Новое правило</span>
<span class="sub">{t("policy.rule.new")}</span>
</span>
</div>
<Button
@@ -301,7 +303,7 @@
smaller
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
</div>
<div class="editor" {@attach reveal}>
@@ -318,17 +320,17 @@
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить политику.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
<p>{t("policy.loadFailed")}</p>
<Button onclick={reload} smaller variant="text">{t("ui.retry")}</Button>
</div>
{:else}
<div class="policy">
{#if problem}
<p class="problem">{problem}</p>
<p class="problem">{t(problem)}</p>
{/if}
<section>
<div class="section-title"><h3>По умолчанию</h3></div>
<div class="section-title"><h3>{t("policy.section.defaults")}</h3></div>
{#each defaults as rule (rule.id)}
{@render row(rule, true)}
{/each}
@@ -336,8 +338,8 @@
<section>
<div class="section-title">
<h3>Папки</h3>
<span>Что сохраняют. Из подходящих папок побеждает первая.</span>
<h3>{t("policy.section.folders")}</h3>
<span>{t("policy.section.foldersHint")}</span>
</div>
{#each folderRules as rule (rule.id)}
{#if draft && draftBefore(rule)}
@@ -356,7 +358,7 @@
type="button"
>
<Icon name="add" size="1.25rem" />
<span>Правило для папки</span>
<span>{t("policy.add.folder")}</span>
</button>
{#if picking === "folder"}
<div class="picker">
@@ -376,8 +378,8 @@
<section>
<div class="section-title">
<h3>Чаты</h3>
<span>Что меняют против папки или умолчания. Важнее папок.</span>
<h3>{t("policy.section.chats")}</h3>
<span>{t("policy.section.chatsHint")}</span>
</div>
{#if draft?.scopeType === "chat"}
{@render pending(draft)}
@@ -392,33 +394,29 @@
type="button"
>
<Icon name="add" size="1.25rem" />
<span>Правило для чата</span>
<span>{t("policy.add.chat")}</span>
</button>
{#if picking === "chat"}
<div class="picker">
<input
aria-label="Поиск чата"
aria-label={t("ui.searchChat")}
class="filter"
oninput={() => picker.search(chatFilter)}
placeholder="Поиск чата"
placeholder={t("ui.searchChat")}
type="text"
bind:value={chatFilter}
>
{#each freeChats as chat (chat.chat_id)}
{@const name = chat.title ?? t("ui.chat", { id: chat.chat_id })}
<button
class="pick"
onclick={() =>
start(
"chat",
chat.chat_id,
chat.title ?? `Чат ${chat.chat_id}`
)}
onclick={() => start("chat", chat.chat_id, name)}
type="button"
>
{chat.title ?? `Чат ${chat.chat_id}`}
{name}
</button>
{:else}
<p class="nothing">Ничего не найдено.</p>
<p class="nothing">{t("ui.nothingFound")}</p>
{/each}
</div>
{/if}
@@ -24,35 +24,11 @@
formatSpan,
isOnline,
} from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js";
import { page } from "$app/state";
const TEXT = {
average: "В среднем за день",
byDay: "По дням",
byHour: "По часам недели",
failed: "Не удалось загрузить аналитику.",
firstOnline: "Обычно появляется",
hidden:
"Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
lastOffline: "Обычно уходит",
median: "Обычная сессия",
messages: "Сообщения",
mine: "Я отвечаю",
missing: "Нет данных",
noData: "нет данных",
noMessages: "За этот период сообщений нет.",
noStatus: "Статусов пока нет.",
openChat: "Откройте чат",
period: (count: number) => `${count} дней`,
retry: "Повторить",
sessions: "Сессии",
theirs: "Мне отвечают",
title: "Аналитика",
total: (count: number) => `в сети за ${count} дней`,
};
const PERIODS = [7, 30, 90];
const DEFAULT_PERIOD = 30;
const SESSION_PAGE = 30;
@@ -60,7 +36,7 @@
const MS_IN_SECOND = 1000;
const tz = browserTimeZone();
const zone = timeZoneLabel();
const zone = $derived(timeZoneLabel());
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
@@ -97,25 +73,25 @@
const result: { label: string; value: string }[] = [];
if (summary.daily_seconds !== null) {
result.push({
label: TEXT.average,
label: t("presence.average"),
value: formatSpan(summary.daily_seconds),
});
}
if (summary.median_session_seconds !== null) {
result.push({
label: TEXT.median,
label: t("presence.median"),
value: formatSpan(summary.median_session_seconds),
});
}
if (summary.first_online !== null) {
result.push({
label: TEXT.firstOnline,
label: t("presence.firstOnline"),
value: formatClock(summary.first_online),
});
}
if (summary.last_offline !== null) {
result.push({
label: TEXT.lastOffline,
label: t("presence.lastOffline"),
value: formatClock(summary.last_offline),
});
}
@@ -124,13 +100,16 @@
MS_IN_SECOND;
const missing = period - summary.covered_seconds;
if (missing >= SECONDS_IN_HOUR) {
result.push({ label: TEXT.missing, value: formatLongSpan(missing) });
result.push({
label: t("presence.missing"),
value: formatLongSpan(missing),
});
}
return result;
});
function reply(seconds: number | null): string {
return seconds === null ? TEXT.noData : formatSpan(seconds);
return seconds === null ? t("presence.noData") : formatSpan(seconds);
}
async function load(id: number, period: number) {
@@ -172,7 +151,7 @@
</script>
{#if chatId === null}
<EmptyState description={TEXT.openChat} title={TEXT.title} />
<EmptyState description={t("profile.empty")} title={t("profile.analytics")} />
{:else}
<div class="analytics">
<div class="periods">
@@ -185,20 +164,20 @@
}}
type="button"
>
{TEXT.period(period)}
{t("presence.days", { count: period })}
</button>
{/each}
</div>
{#if failed}
<div class="state">
<p>{TEXT.failed}</p>
<p>{t("presence.failed")}</p>
<Button
onclick={() => chatId !== null && load(chatId, days)}
smaller
variant="text"
>
{TEXT.retry}
{t("ui.retry")}
</Button>
</div>
{:else if volume === null || (isDm && stats === null)}
@@ -211,7 +190,9 @@
<span class="total"
>{formatSpan(stats.summary.online_seconds)}</span
>
<span class="count">{TEXT.total(shownDays)}</span>
<span class="count"
>{t("presence.total", { count: shownDays })}</span
>
</div>
{/if}
{#if sample}
@@ -220,9 +201,9 @@
</p>
{/if}
{#if stats.hidden}
<p class="note">{TEXT.hidden}</p>
<p class="note">{t("presence.hidden")}</p>
{:else if !tracked}
<p class="note">{TEXT.noStatus}</p>
<p class="note">{t("presence.noStatus")}</p>
{/if}
</section>
@@ -242,34 +223,34 @@
<section>
<div class="section-title">
<h3>{TEXT.byHour}</h3>
<h3>{t("presence.byHour")}</h3>
<span>{zone}</span>
</div>
<PresenceHeatmap week={stats.week} />
</section>
<section>
<div class="section-title"><h3>{TEXT.byDay}</h3></div>
<div class="section-title"><h3>{t("presence.byDay")}</h3></div>
<OnlineDays days={stats.days} />
</section>
{/if}
{/if}
<section>
<div class="section-title"><h3>{TEXT.messages}</h3></div>
<div class="section-title"><h3>{t("presence.messages")}</h3></div>
{#if quiet}
<p class="state">{TEXT.noMessages}</p>
<p class="state">{t("presence.noMessages")}</p>
{:else}
<VolumeChart {volume} />
{/if}
{#if response && response.mine_count + response.their_count > 0}
<dl class="rows replies">
<div class="row">
<dt>{TEXT.mine}</dt>
<dt>{t("presence.mine")}</dt>
<dd>{reply(response.mine_median_seconds)}</dd>
</div>
<div class="row">
<dt>{TEXT.theirs}</dt>
<dt>{t("presence.theirs")}</dt>
<dd>{reply(response.their_median_seconds)}</dd>
</div>
</dl>
@@ -278,7 +259,7 @@
{#if isDm && stats && tracked && !stats.hidden}
<section>
<div class="section-title"><h3>{TEXT.sessions}</h3></div>
<div class="section-title"><h3>{t("presence.sessions")}</h3></div>
{#key `${chatId}:${shownDays}:${stats.until}`}
<PresenceSessions
days={shownDays}
@@ -2,6 +2,7 @@
import type { PresenceDay } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
days: PresenceDay[];
@@ -12,18 +13,6 @@
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const TEXT = {
bar: (day: string, online: number, missing: number) => {
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return `${day}, нет данных`;
}
const gap =
missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : "";
return `${day}, ${formatSpan(online)} в сети${gap}`;
},
today: "сегодня",
};
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
const first = $derived(days[0]);
@@ -31,6 +20,23 @@
return formatDay(parseDay(day).toISOString());
}
function title(day: PresenceDay, index: number): string {
const date = label(day.day);
const missing = absent(day, index);
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return t("presence.dayNoData", { day: date });
}
const span = formatSpan(day.online_seconds);
if (missing >= SECONDS_IN_HOUR) {
return t("presence.dayGap", {
day: date,
gap: formatSpan(missing),
span,
});
}
return t("presence.day", { day: date, span });
}
function absent(day: PresenceDay, index: number): number {
if (index === days.length - 1) {
return 0;
@@ -46,11 +52,7 @@
{#each days as day, index (day.day)}
<div
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
title={TEXT.bar(
label(day.day),
day.online_seconds,
absent(day, index)
)}
title={title(day, index)}
>
<span
class={["bar", { zero: day.online_seconds === 0 }]}
@@ -63,7 +65,7 @@
{#if first}
<div class="axis">
<span>{label(first.day)}</span>
<span>{TEXT.today}</span>
<span>{t("presence.axisToday")}</span>
</div>
{/if}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan } from "#lib/format/presence.js";
import { formatSpan, weekdayLabels } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
week: PresenceHour[];
@@ -8,28 +9,14 @@
const { week }: Props = $props();
const TEXT = {
cell: (day: string, cell: PresenceHour) => {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return `${slot}, нет данных`;
}
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
},
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
empty: "нет данных",
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
};
const HOURS_IN_DAY = 24;
const SECONDS_IN_MINUTE = 60;
const HOUR_TICKS = [0, 6, 12, 18];
const MIN_SHADE = 0.18;
const weekdays = $derived(weekdayLabels());
const rows = $derived(
TEXT.days.map((_, weekday) =>
weekdays.map((_, weekday) =>
week
.filter((cell) => cell.weekday === weekday)
.sort((a, b) => a.hour - b.hour)
@@ -44,6 +31,20 @@
return cell.hours > 0 && cell.online_minutes === null;
}
function title(day: string, cell: PresenceHour): string {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return t("presence.cellNoData", { slot });
}
return t("presence.cellAverage", {
slot,
span: formatSpan(cell.online_minutes * SECONDS_IN_MINUTE),
});
}
function shade(minutes: number | null): number {
if (minutes === null || minutes === 0 || peak === 0) {
return 0;
@@ -55,7 +56,7 @@
<div class="heatmap">
{#each rows as cells, weekday (weekday)}
<div class="row">
<span class="day">{TEXT.days[weekday]}</span>
<span class="day">{weekdays[weekday]}</span>
<div class="cells">
{#each cells as cell (cell.hour)}
<span
@@ -67,7 +68,7 @@
zero: cell.online_minutes === 0,
},
]}
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)}
title={title(weekdays[weekday] ?? "", cell)}
style:--shade="{shade(cell.online_minutes) * 100}%"
></span>
{/each}
@@ -87,13 +88,13 @@
{#if peak > 0}
<span class="key">
<span class="scale"></span>
{TEXT.max(peak)}
{t("presence.max", { span: formatSpan(peak * SECONDS_IN_MINUTE) })}
</span>
{/if}
{#if hasGaps}
<span class="key">
<span class="swatch empty"></span>
{TEXT.empty}
{t("presence.noData")}
</span>
{/if}
</div>
@@ -115,7 +116,7 @@
.day {
flex-shrink: 0;
width: 1.375rem;
width: 1.75rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
@@ -173,7 +174,7 @@
gap: 0.25rem 0.75rem;
align-items: center;
margin: 0.375rem 0 0 1.875rem;
margin: 0.375rem 0 0 2.25rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
@@ -5,6 +5,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
days: number;
@@ -16,18 +17,6 @@
const { days, first, pageSize, peerId, tz }: Props = $props();
const TEXT = {
failed: "Не удалось загрузить сессии.",
more: "Показать ещё",
none: "За этот период в сети не был(а).",
now: "в сети",
retry: "Повторить",
since: (time: string) => `с ${time}`,
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
today: "Сегодня",
yesterday: "Вчера",
};
const MS_IN_DAY = 86_400_000;
let extra = $state<PresenceSession[]>([]);
@@ -54,18 +43,22 @@
function dayLabel(date: Date): string {
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return TEXT.today;
return t("presence.today");
}
if (
date.toDateString() ===
new Date(today.getTime() - MS_IN_DAY).toDateString()
) {
return TEXT.yesterday;
return t("presence.yesterday");
}
const text = formatLongDay(date);
return text.charAt(0).toUpperCase() + text.slice(1);
}
function span(from: string, to: string): string {
return from === to ? from : `${from}–${to}`;
}
async function loadMore() {
if (loading) {
return;
@@ -92,7 +85,7 @@
</script>
{#if sessions.length === 0}
<p class="state">{TEXT.none}</p>
<p class="state">{t("presence.noSessions")}</p>
{:else}
{#each groups as group (group.label)}
<h4 class="day">{group.label}</h4>
@@ -101,14 +94,13 @@
<li>
<span>
{session.ongoing
? TEXT.since(formatTime(session.started))
: TEXT.span(
formatTime(session.started),
formatTime(session.ended)
)}
? t("presence.since", { time: formatTime(session.started) })
: span(formatTime(session.started), formatTime(session.ended))}
</span>
<span class={["length", { online: session.ongoing }]}>
{session.ongoing ? TEXT.now : formatSpan(session.seconds)}
{session.ongoing
? t("presence.online")
: formatSpan(session.seconds)}
</span>
</li>
{/each}
@@ -118,11 +110,13 @@
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button>
<p>{t("presence.sessionsFailed")}</p>
<Button onclick={loadMore} smaller variant="text">{t("ui.retry")}</Button>
</div>
{:else if !exhausted}
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button>
<button class="more" onclick={loadMore} type="button">
{t("ui.showMore")}
</button>
{/if}
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import type { VolumeBucket } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
volume: VolumeBucket[];
@@ -8,22 +9,25 @@
const { volume }: Props = $props();
const TEXT = {
bar: (day: string, bucket: VolumeBucket) =>
bucket.total === 0
? `${day}, нет сообщений`
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
incoming: "входящие",
outgoing: "мои",
today: "сегодня",
};
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const first = $derived(volume[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
}
function title(bucket: VolumeBucket): string {
const day = label(bucket.bucket);
if (bucket.total === 0) {
return t("presence.volumeEmpty", { day });
}
return t("presence.volume", {
day,
incoming: bucket.incoming,
outgoing: bucket.outgoing,
total: bucket.total,
});
}
</script>
<div class="volume">
@@ -31,7 +35,7 @@
<span class="peak">{peak}</span>
<div class={["bars", { dense: volume.length > 45 }]}>
{#each volume as bucket (bucket.bucket)}
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
<div class="slot" title={title(bucket)}>
<div
class={["bar", { zero: bucket.total === 0 }]}
style:height="{(bucket.total / peak) * 100}%"
@@ -46,12 +50,16 @@
{#if first}
<div class="axis">
<span>{label(first.bucket)}</span>
<span>{TEXT.today}</span>
<span>{t("presence.axisToday")}</span>
</div>
{/if}
<div class="legend">
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
<span class="key"
><span class="swatch in"></span>{t("presence.volumeIncoming")}</span
>
<span class="key"
><span class="swatch out"></span>{t("presence.volumeOutgoing")}</span
>
</div>
</div>
@@ -1,40 +1,42 @@
<script lang="ts">
import { untrack } from "svelte";
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
import type { DayCount } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const LEVELS = 4;
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const MONTHS = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
import { page } from "$app/state";
interface YearMonth {
month: number;
year: number;
}
interface Props {
chatId: number;
initial?: YearMonth | null;
onjump?: () => void;
}
let { chatId, initial = null, onjump }: Props = $props();
const LEVELS = 4;
const MONDAY = Date.UTC(2024, 0, 1);
const DAY_MS = 86_400_000;
const weekdays = $derived.by(() => {
const names = new Intl.DateTimeFormat(locale.tag, {
timeZone: "UTC",
weekday: "short",
});
return Array.from({ length: 7 }, (_, offset) =>
names.format(new Date(MONDAY + offset * DAY_MS))
);
});
interface DayCell {
count: number;
day: number;
@@ -44,7 +46,7 @@
let days = $state<DayCount[]>([]);
let loading = $state(false);
let cursor = $state<YearMonth | null>(null);
let cursor = $state<YearMonth | null>(untrack(() => initial));
const cache = new Map<string, DayCount[]>();
const now = new Date();
@@ -61,6 +63,13 @@
);
const view = $derived(cursor ?? currentMonth);
const monthLabel = $derived.by(() => {
const name = new Intl.DateTimeFormat(locale.tag, {
month: "long",
timeZone: "UTC",
}).format(new Date(Date.UTC(view.year, view.month, 1)));
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${view.year}`;
});
function index(value: YearMonth): number {
return value.year * 12 + value.month;
@@ -115,9 +124,14 @@
return result;
});
let shown = untrack(() => `${chatId}:${accounts.selectedId}`);
$effect(() => {
const _id = chatId;
const _account = accounts.selectedId;
const key = `${chatId}:${accounts.selectedId}`;
if (key === shown) {
return;
}
shown = key;
cache.clear();
cursor = null;
days = [];
@@ -161,7 +175,10 @@
try {
const target = await getMessageAt(chatId, cell.key);
ui.requestJump(chatId, target.message_id);
goto(`/app/${chatId}`);
onjump?.();
if (page.params.chatId !== String(chatId)) {
goto(`/app/${chatId}`);
}
} catch {
// no message resolved for that day
}
@@ -174,7 +191,7 @@
<div class="calendar">
<header class="nav">
<button
aria-label="Предыдущий год"
aria-label={t("chat.calendar.prevYear")}
class="step"
onclick={() => shift(-12)}
type="button"
@@ -182,16 +199,16 @@
«
</button>
<button
aria-label="Предыдущий месяц"
aria-label={t("chat.calendar.prevMonth")}
class="step"
onclick={() => shift(-1)}
type="button"
>
<Icon name="arrow-left" />
</button>
<span class="label">{MONTHS[view.month]} {view.year}</span>
<span class="label">{monthLabel}</span>
<button
aria-label="Следующий месяц"
aria-label={t("chat.calendar.nextMonth")}
class="step"
disabled={!canNext}
onclick={() => shift(1)}
@@ -200,7 +217,7 @@
<Icon name="arrow-right" />
</button>
<button
aria-label="Следующий год"
aria-label={t("chat.calendar.nextYear")}
class="step"
disabled={!canNext}
onclick={() => shift(12)}
@@ -211,7 +228,7 @@
</header>
<div class="weekdays">
{#each WEEKDAYS as weekday (weekday)}
{#each weekdays as weekday (weekday)}
<span>{weekday}</span>
{/each}
</div>
@@ -225,7 +242,7 @@
<button
class="cell day lvl-{cell.level}"
onclick={() => jump(cell)}
title={`${cell.count} сообщений`}
title={t("chat.calendar.count", { count: cell.count })}
type="button"
>
{cell.day}
@@ -18,6 +18,7 @@
import ProfileSection from "#lib/components/profile/ProfileSection.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatDate, formatFull } from "#lib/format/datetime.js";
import { formatPhone } 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";
@@ -56,7 +57,7 @@
result.push({
icon: "phone",
label: t("profile.phone"),
value: `+${peer.phone}`,
value: formatPhone(peer.phone),
});
}
if (peer?.username) {
@@ -9,7 +9,7 @@
import Avatar from "#lib/components/ui/Avatar.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js";
import { formatPhone, peerName } from "#lib/format/peer.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";
@@ -73,7 +73,7 @@
if (peer?.username) {
return `@${peer.username}`;
}
return peer?.phone ? `+${peer.phone}` : `ID ${targetId}`;
return peer?.phone ? formatPhone(peer.phone) : `ID ${targetId}`;
}
const count = chat?.message_count ?? 0;
return count > 0 ? t("profile.messages", { count }) : t("profile.group");
@@ -7,6 +7,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
@@ -83,7 +84,7 @@
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет ссылок" />
<div class="center"><EmptyState title={t("social.links.empty")} /></div>
{/if}
{:else}
<ul class="list">
@@ -122,7 +123,7 @@
onclick={() => loadMore()}
type="button"
>
{loading ? "Загрузка…" : "Показать ещё"}
{loading ? t("ui.loading") : t("ui.showMore")}
</button>
{/if}
@@ -17,6 +17,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
@@ -56,14 +57,16 @@
);
function displayName(item: MediaView): string {
return item.file_name ?? mediaKindLabel(item.kind) ?? "Файл";
return (
item.file_name ?? mediaKindLabel(item.kind) ?? t("media.kind.document")
);
}
async function save(item: MediaView) {
try {
await downloadMedia(item.id);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("ui.downloadFailed"));
}
}
@@ -175,7 +178,7 @@
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Пусто" />
<div class="center"><EmptyState title={t("shared.media.empty")} /></div>
{/if}
{:else if layout === "grid"}
<div class="grid">
@@ -217,17 +220,17 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть на весь экран</ContextMenuItem
>{t("shared.media.fullscreen")}</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>{t("ui.jumpToMessage")}</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>{t("ui.downloadFile")}</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>{t("ui.shareLink")}</ContextMenuItem
>
{/if}
{/snippet}
@@ -262,17 +265,17 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть</ContextMenuItem
>{t("ui.open")}</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>{t("ui.jumpToMessage")}</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>{t("ui.downloadFile")}</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>{t("ui.shareLink")}</ContextMenuItem
>
{/if}
{/snippet}
@@ -289,7 +292,7 @@
onclick={() => loadMore()}
type="button"
>
{loading ? "Загрузка…" : "Показать ещё"}
{loading ? t("ui.loading") : t("ui.showMore")}
</button>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from "svelte";
import { visible } from "#lib/actions/visible.js";
import SearchEverywhere from "#lib/components/search/SearchEverywhere.svelte";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
@@ -15,7 +16,9 @@
const DEBOUNCE_MS = 250;
const pager = createHitPager();
const everywhere = createHitPager();
let query = $state("");
let deep = $state(false);
let typing = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -23,14 +26,30 @@
page.params.chatId ? Number(page.params.chatId) : null
);
const needle = $derived(query.trim());
const empty = $derived(
needle !== "" &&
!(typing || deep || pager.loading || pager.failed) &&
pager.hits.length === 0
);
function run() {
typing = false;
const value = query.trim();
deep = false;
everywhere.run(null);
pager.run(
value && chatId !== null ? { chat_id: chatId, query: value } : null
needle && chatId !== null ? { chat_id: chatId, query: needle } : null
);
}
function searchEverywhere() {
if (chatId === null) {
return;
}
deep = true;
everywhere.run({ chat_id: chatId, everywhere: true, query: needle });
}
$effect(() => {
if (chatId !== null) {
untrack(run);
@@ -46,11 +65,11 @@
timer = setTimeout(run, DEBOUNCE_MS);
}
function openHit(messageId: number) {
function openHit(messageId: number, marks: string[]) {
if (chatId === null) {
return;
}
ui.requestJump(chatId, messageId);
ui.requestJump(chatId, messageId, marks);
if (isMobile()) {
ui.closePanel();
}
@@ -73,9 +92,9 @@
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem
{hit}
href={search.hitHref(hit.chat_id, hit.message_id)}
href={search.hitHref(hit.chat_id, hit.message_id, pager.terms)}
inChat
onclick={() => openHit(hit.message_id)}
onclick={() => openHit(hit.message_id, pager.terms)}
terms={pager.terms}
/>
{/each}
@@ -96,7 +115,19 @@
>{t("search.retry")}</Button
>
</div>
{:else if query.trim() && !typing}
{/if}
<SearchEverywhere
href={(hit) => search.hitHref(hit.chat_id, hit.message_id, [needle])}
inChat
{needle}
onopen={(hit) => openHit(hit.message_id, [needle])}
onstart={searchEverywhere}
pager={everywhere}
ready={!(typing || pager.loading || pager.failed || pager.hasMore)}
shown={pager.hits}
started={deep}
/>
{#if empty}
<EmptyState title={t("search.nothing")} />
{/if}
</div>
@@ -0,0 +1,208 @@
<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import { visible } from "#lib/actions/visible.js";
import type { SearchHit } from "#lib/api/types.js";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import {
EVERYWHERE_MIN,
type HitPager,
hitKey,
} from "#lib/stores/search.svelte.js";
interface Props {
href: (hit: SearchHit) => string;
inChat?: boolean;
needle: string;
onopen?: (hit: SearchHit) => void;
onstart: () => void;
pager: HitPager;
ready: boolean;
shown: SearchHit[];
started: boolean;
}
let {
href,
inChat = false,
needle,
onopen,
onstart,
pager,
ready,
shown,
started,
}: Props = $props();
const hits = $derived.by(() => {
const seen = new Set(shown.map(hitKey));
return pager.hits.filter((hit) => {
const key = hitKey(hit);
const fresh = !seen.has(key);
seen.add(key);
return fresh;
});
});
const busy = $derived(pager.loading || pager.appending);
function retry() {
if (pager.hasMore) {
pager.more();
} else {
pager.retry();
}
}
</script>
{#if !started}
{#if ready && needle.length >= EVERYWHERE_MIN}
<button
class="start"
onclick={onstart}
type="button"
class:in-chat={inChat}
use:ripple
>
<span class="glyph"><Icon name="search" size="1.5rem" /></span>
<span class="body">
<span class="label">{t("search.everywhere")}</span>
<span class="hint">{t("search.everywhereHint")}</span>
</span>
</button>
{/if}
{:else}
{#if hits.length > 0}
<div class="section-label">{t("search.everywhereSection")}</div>
{#each hits as hit (hitKey(hit))}
<SearchMessageItem
{hit}
href={href(hit)}
{inChat}
onclick={() => onopen?.(hit)}
terms={[needle]}
/>
{/each}
{/if}
{#if busy}
<div class="status">
<Spinner size="1.25rem" />
<span>
{pager.scannedTo
? t("search.everywhereScanned", { date: formatDate(pager.scannedTo) })
: t("search.everywhereScanning")}
</span>
</div>
{:else if pager.failed}
<div class="status failed">
<span>{t("search.failed")}</span>
<Button onclick={retry} variant="text">{t("search.retry")}</Button>
</div>
{:else if pager.hasMore}
<div class="sentinel" use:visible={() => pager.more()}></div>
{:else if hits.length === 0}
<div class="status">{t("search.everywhereNothing")}</div>
{/if}
{/if}
<style lang="scss">
.start {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
color: var(--color-primary);
.in-chat & {
width: 2.5rem;
height: 2.5rem;
}
}
.body {
display: flex;
flex-direction: column;
gap: 0.1875rem;
min-width: 0;
}
.label {
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.hint {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.section-label {
padding: 0.625rem 0.75rem 0.375rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 0.75rem 1.5rem;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
&.failed {
flex-direction: column;
}
}
.sentinel {
height: 1px;
}
</style>
@@ -1,12 +1,12 @@
<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import type { SearchHit } from "#lib/api/types.js";
import type { FoundWhere, SearchHit } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { mediaKindLabel } from "#lib/format/media.js";
import { peerName } from "#lib/format/peer.js";
import { highlight } from "#lib/format/search.js";
import { highlight, highlightMarks } from "#lib/format/search.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
@@ -22,6 +22,17 @@
let { hit, href, inChat = false, onclick, terms }: Props = $props();
const FOUND_ICONS: Record<FoundWhere, string> = {
button: "bot-command",
data: "info",
file: "document",
forward: "forward",
link: "link",
preview: "link",
reply: "reply",
text: "search",
};
const chat = $derived(chats.byId(hit.chat_id));
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
@@ -81,7 +92,21 @@
const kind = $derived(
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
);
const segments = $derived(highlight(body, terms));
const byData = $derived(hit.source === "data");
const segments = $derived(
byData ? highlightMarks(body, terms) : highlight(body, terms)
);
const found = $derived(
hit.found && hit.found.where !== "text" ? hit.found : null
);
const foundLabel = $derived.by(() => {
if (found === null) {
return "";
}
return found.where === "button"
? t("search.found.button", { text: found.button ?? "" })
: t(`search.found.${found.where}`);
});
</script>
<a class="hit" {href} {onclick} use:ripple>
@@ -126,6 +151,21 @@
{/each}</span
>
</div>
{#if found}
<div class="note found" title={found.path}>
<Icon name={FOUND_ICONS[found.where]} size="0.875rem" />
<span class="found-label">{foundLabel}:</span>
<span class="found-value"
>{#each highlightMarks(found.value, terms) as segment, index (index)}
{#if segment.hit}
<mark>{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}</span
>
</div>
{/if}
{#if hit.source === "version"}
<div
class="note"
@@ -244,6 +284,22 @@
color: var(--color-text-secondary);
}
.found {
font-style: normal;
white-space: nowrap;
}
.found-label {
flex-shrink: 0;
color: var(--color-text);
}
.found-value {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
}
mark {
padding: 0 0.0625rem;
border-radius: 0.25rem;
@@ -3,6 +3,7 @@
import { visible } from "#lib/actions/visible.js";
import ChatListItem from "#lib/components/ChatListItem.svelte";
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
import SearchEverywhere from "#lib/components/search/SearchEverywhere.svelte";
import SearchFilters from "#lib/components/search/SearchFilters.svelte";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
import Button from "#lib/components/ui/Button.svelte";
@@ -14,7 +15,7 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
const { messages } = search;
const { everywhere, messages } = search;
let scroller = $state<HTMLDivElement | null>(null);
$effect(() => {
@@ -25,12 +26,22 @@
});
});
$effect(() => {
const key = search.everywhereRequest;
untrack(() => search.syncEverywhere(key));
});
const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const showChats = $derived(!search.filtered && search.chatHits.length > 0);
const showPeers = $derived(!search.filtered && search.peerHits.length > 0);
const hasMessages = $derived(messages.hits.length > 0);
const needle = $derived(search.text.trim());
const searchedAll = $derived(
!(search.typing || messages.loading || messages.failed || messages.hasMore)
);
const deep = $derived(search.everywhereRequest !== "");
const empty = $derived(
!(
search.typing ||
@@ -38,7 +49,8 @@
messages.failed ||
showChats ||
showPeers ||
hasMessages
hasMessages ||
deep
)
);
@@ -74,8 +86,9 @@
{#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem
{hit}
href={search.hitHref(hit.chat_id, hit.message_id)}
onclick={() => ui.requestJump(hit.chat_id, hit.message_id)}
href={search.hitHref(hit.chat_id, hit.message_id, messages.terms)}
onclick={() =>
ui.requestJump(hit.chat_id, hit.message_id, messages.terms)}
terms={messages.terms}
/>
{/each}
@@ -98,6 +111,17 @@
</div>
{/if}
<SearchEverywhere
href={(hit) => search.hitHref(hit.chat_id, hit.message_id, [needle])}
{needle}
onopen={(hit) => ui.requestJump(hit.chat_id, hit.message_id, [needle])}
onstart={() => search.searchEverywhere()}
pager={everywhere}
ready={searchedAll}
shown={messages.hits}
started={deep}
/>
{#if empty}
<EmptyState title={t("search.nothing")} />
{/if}
@@ -14,6 +14,8 @@
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import QrCode from "#lib/components/ui/QrCode.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -30,12 +32,9 @@
let busy = $state(false);
let attempt = 0;
const hints: Record<Step, string> = {
code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.",
phone: "Номер телефона в международном формате, например +79991234567.",
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
};
function hint(current: Step): string {
return t(`login.hint.${current}`);
}
const filled = $derived.by(() => {
if (step === "phone") {
@@ -56,14 +55,13 @@
loginId = "";
await accounts.load();
if (state.account) {
accounts.select(state.account.account_id);
accounts.switchTo(state.account.account_id);
}
toasts.success("Аккаунт добавлен");
open = false;
}
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
function fail(error: unknown, fallback: MessageKey) {
toasts.error(error instanceof ApiError ? error.detail : t(fallback));
}
function drop() {
@@ -89,7 +87,7 @@
}
} catch (error) {
if (mine === attempt) {
fail(error, "Не удалось получить QR-код");
fail(error, "login.qrFailed");
}
}
}
@@ -135,7 +133,7 @@
try {
await apply(await next());
} catch (error) {
fail(error, "Не удалось войти");
fail(error, "login.failed");
} finally {
busy = false;
}
@@ -159,29 +157,29 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">{t("login.title")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
{#if step === "qr"}
<div class="dialog-body">
<p class="hint">{hints.qr}</p>
<p class="hint">{hint("qr")}</p>
<div class="qr-slot">
{#if qrUrl}
<QrCode label="QR-код для входа в Telegram" value={qrUrl} />
<QrCode label={t("login.qrLabel")} value={qrUrl} />
{/if}
</div>
</div>
<div class="dialog-actions">
<Button onclick={usePhone} pill variant="text">
Войти по номеру телефона
{t("login.byPhone")}
</Button>
</div>
{:else}
<form onsubmit={submit}>
<div class="dialog-body">
<p class="hint">{hints[step]}</p>
<p class="hint">{hint(step)}</p>
{#if step === "phone"}
<div class="input-group">
<input
@@ -192,7 +190,7 @@
type="tel"
bind:value={phone}
>
<label for="login-phone">Номер телефона</label>
<label for="login-phone">{t("login.phone")}</label>
</div>
{:else if step === "code"}
<div class="input-group">
@@ -205,7 +203,7 @@
type="text"
bind:value={code}
>
<label for="login-code">Код подтверждения</label>
<label for="login-code">{t("login.code")}</label>
</div>
{:else}
<div class="input-group">
@@ -213,17 +211,19 @@
autocomplete="current-password"
class="form-control"
id="login-password"
placeholder="Пароль"
placeholder={t("login.passwordPlaceholder")}
type="password"
bind:value={password}
>
<label for="login-password">Облачный пароль</label>
<label for="login-password">{t("login.password")}</label>
</div>
{/if}
</div>
<div class="dialog-actions">
{#if step === "phone"}
<Button onclick={useQr} pill variant="text">QR-код</Button>
<Button onclick={useQr} pill variant="text">
{t("login.qr")}
</Button>
{/if}
<Button
disabled={busy || !filled}
@@ -231,7 +231,7 @@
pill
type="submit"
>
{step === "phone" ? "Отправить код" : "Продолжить"}
{step === "phone" ? t("login.sendCode") : t("login.continue")}
</Button>
</div>
</form>
@@ -247,6 +247,10 @@
color: var(--color-text-secondary);
}
.form-control:not(:focus)::placeholder {
color: transparent;
}
.qr-slot {
display: grid;
place-items: center;
@@ -0,0 +1,58 @@
<script lang="ts">
import { captureLine } from "#lib/format/capture.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
const status = $derived(capture.status);
</script>
{#if status}
<div class="capture-line" role="status" class:quiet={!status.live}>
<span class="mark"><span class="dot"></span></span>
<span class="label"
>{captureLine(status, accounts.selected?.is_bot ?? false)}</span
>
</div>
{/if}
<style lang="scss">
.capture-line {
display: flex;
align-items: center;
gap: 1.5rem;
min-height: 3.5rem;
padding: 0 1.25rem;
font-size: 1rem;
color: var(--color-text);
}
.mark {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
}
.dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-green);
}
.quiet .dot {
background-color: var(--color-error);
}
.label {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -7,7 +7,7 @@
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { accountName } from "#lib/format/peer.js";
import { accountName, formatPhone } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -78,7 +78,7 @@
<AccountAvatar account={current} size={5} />
<div class="name">{accountName(current)}</div>
{#if current.phone}
<div class="phone">+{current.phone}</div>
<div class="phone">{formatPhone(current.phone)}</div>
{/if}
{#if !current.is_active}
<div class="inactive">{t("account.inactive")}</div>
@@ -2,6 +2,7 @@
import AlertBadge from "#lib/components/alerts/AlertBadge.svelte";
import About from "#lib/components/settings/About.svelte";
import Appearance from "#lib/components/settings/Appearance.svelte";
import CaptureLine from "#lib/components/settings/CaptureLine.svelte";
import Language from "#lib/components/settings/Language.svelte";
import MyAccount from "#lib/components/settings/MyAccount.svelte";
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
@@ -39,6 +40,7 @@
</div>
<div class="section">
<CaptureLine />
<SettingsItem
icon="settings"
label={t("settings.policy")}
@@ -83,6 +85,11 @@
<AlertBadge inline />
{/snippet}
</SettingsItem>
<SettingsItem
icon="note"
label={t("notes.title")}
onclick={() => ui.openPanel("notes")}
/>
</div>
<About />
@@ -17,43 +17,53 @@
import QrCode from "#lib/components/ui/QrCode.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatShareLimits } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
label?: string;
label?: string | null;
onchange?: (share: FileShare | null) => void;
open: boolean;
subject: ShareSubject | null;
}
let {
open = $bindable(),
subject,
label = "файл",
onchange,
}: Props = $props();
let { open = $bindable(), subject, label, onchange }: Props = $props();
interface Choice {
label: () => string;
value: number | null;
}
const HOUR = 3600;
const DAY = 24 * HOUR;
const DAY_HOURS = 24;
const WEEK_DAYS = 7;
const MONTH_DAYS = 30;
const KEEP = -1;
const LIMITS = [1, 5, 25, 100];
const expiryChoices = [
{ label: "Бессрочно", value: null },
{ label: "1 час", value: HOUR },
{ label: "24 часа", value: DAY },
{ label: "7 дней", value: 7 * DAY },
{ label: "30 дней", value: 30 * DAY },
const expiryChoices: Choice[] = [
{ label: () => t("shares.noExpiry"), value: null },
{ label: () => t("shares.hours", { count: 1 }), value: HOUR },
{ label: () => t("shares.hours", { count: DAY_HOURS }), value: DAY },
{
label: () => t("shares.days", { count: WEEK_DAYS }),
value: WEEK_DAYS * DAY,
},
{
label: () => t("shares.days", { count: MONTH_DAYS }),
value: MONTH_DAYS * DAY,
},
];
const keepChoice = { label: "Как есть", value: KEEP };
const keepChoice: Choice = { label: () => t("shares.keep"), value: KEEP };
const limitChoices = [
{ label: "Без лимита", value: null },
{ label: "1", value: 1 },
{ label: "5", value: 5 },
{ label: "25", value: 25 },
{ label: "100", value: 100 },
const limitChoices: Choice[] = [
{ label: () => t("shares.noLimit"), value: null },
...LIMITS.map((value) => ({ label: () => String(value), value })),
];
let share = $state<FileShare | null>(null);
@@ -63,6 +73,8 @@
let maxDownloads = $state<number | null>(null);
let showQr = $state(false);
let token = 0;
const saved = createFlash<number>();
const revoked = createFlash<ShareSubject>();
const settings = $derived<ShareSettings>({
expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
@@ -73,8 +85,8 @@
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
);
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
function fail(error: unknown, fallback: MessageKey) {
toasts.error(error instanceof ApiError ? error.detail : t(fallback));
}
function adopt(next: FileShare | null) {
@@ -99,7 +111,7 @@
}
} catch (error) {
if (current === token) {
fail(error, "Не удалось проверить ссылку");
fail(error, "shares.checkFailed");
open = false;
}
} finally {
@@ -116,9 +128,8 @@
busy = true;
try {
adopt(await createShare(subject, settings));
toasts.success("Доступ по ссылке открыт");
} catch (error) {
fail(error, "Не удалось открыть доступ");
fail(error, "shares.openFailed");
} finally {
busy = false;
}
@@ -130,27 +141,28 @@
}
busy = true;
try {
adopt(await updateShare(share.id, settings));
toasts.success("Настройки ссылки обновлены");
const next = await updateShare(share.id, settings);
adopt(next);
saved.show(next.id);
} catch (error) {
fail(error, "Не удалось обновить ссылку");
fail(error, "shares.updateFailed");
} finally {
busy = false;
}
}
async function revoke() {
if (!share || busy) {
if (!(share && subject) || busy) {
return;
}
const target = subject;
busy = true;
try {
await revokeShare(share.id);
adopt(null);
toasts.success("Доступ отозван");
open = false;
revoked.show(target);
} catch (error) {
fail(error, "Не удалось отозвать доступ");
fail(error, "shares.revokeFailed");
} finally {
busy = false;
}
@@ -172,8 +184,8 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">{t("ui.shareLink")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
@@ -203,25 +215,24 @@
type="button"
>
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
{showQr ? t("shares.qr.hide") : t("shares.qr.show")}
</button>
</div>
{#if showQr}
<div class="qr-slot">
<QrCode label="QR-код ссылки на файл" value={shareUrl(share)} />
<QrCode label={t("shares.qr.label")} value={shareUrl(share)} />
</div>
{/if}
<p class="hint raw">
Ссылка отдаёт файл как есть: картинки и видео откроются прямо в
браузере, <code>curl -O {shareUrl(share)}</code> скачает под
настоящим именем. Превью-краулеры Telegram скачивания не списывают.
{t("shares.raw.before")}<code>curl -O {shareUrl(share)}</code>
{t("shares.raw.after")}
</p>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<legend>{t("shares.limit")}</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
{#each limitChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -230,16 +241,16 @@
type="button"
class:selected={maxDownloads === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<legend>{t("shares.lifetime")}</legend>
<div class="segments">
{#each liveExpiryChoices as choice (choice.label)}
{#each liveExpiryChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -248,7 +259,7 @@
type="button"
class:selected={expiresIn === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
@@ -259,15 +270,15 @@
><Icon name="lock" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{label}</strong>
<span>Сейчас доступен только вам</span>
<strong>{label ?? t("shares.file")}</strong>
<span>{t("shares.private")}</span>
</div>
</div>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<legend>{t("shares.lifetime")}</legend>
<div class="segments">
{#each expiryChoices as choice (choice.label)}
{#each expiryChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -276,16 +287,16 @@
type="button"
class:selected={expiresIn === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<legend>{t("shares.limit")}</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
{#each limitChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -294,29 +305,38 @@
type="button"
class:selected={maxDownloads === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
</fieldset>
<p class="hint">
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
момент.
</p>
<p class="hint">{t("shares.publicHint")}</p>
{/if}
</div>
{#if !loading}
<div class="dialog-actions">
{#if share}
{#if subject && revoked.current === subject}
<p class="done">
<Icon name="check" size="1.25rem" />
{t("shares.accessRevoked")}
</p>
{:else if share}
<Button loading={busy} onclick={revoke} pill variant="danger">
Отозвать
{t("shares.revoke")}
</Button>
<Button loading={busy} onclick={applyLimits} pill>
{#if saved.current === share.id}
<Icon name="check" />
{t("shares.saved")}
{:else}
{t("common.save")}
{/if}
</Button>
<Button loading={busy} onclick={applyLimits} pill>Сохранить</Button>
{:else}
<Button fluid loading={busy} onclick={publish} pill>
Открыть доступ
{t("shares.publish")}
</Button>
{/if}
</div>
@@ -326,6 +346,20 @@
</Dialog.Root>
<style lang="scss">
.done {
display: flex;
flex: 1;
gap: 0.375rem;
align-items: center;
justify-content: center;
height: 3rem;
margin: 0;
font-weight: var(--font-weight-medium);
color: var(--color-text-green);
}
.center {
display: flex;
justify-content: center;
@@ -1,5 +1,6 @@
<script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
@@ -17,7 +18,7 @@
try {
await navigator.clipboard.writeText(url);
} catch {
toasts.error("Не удалось скопировать ссылку");
toasts.error(t("shares.copyFailed"));
return;
}
copied = true;
@@ -1,4 +1,5 @@
<script lang="ts">
import { SvelteSet } from "svelte/reactivity";
import {
deleteShare,
listShares,
@@ -11,13 +12,16 @@
import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import CopyItem from "#lib/components/ui/CopyItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { formatBytes } from "#lib/format/media.js";
import { formatDownloads, formatExpiry, plural } from "#lib/format/shares.js";
import { formatDownloads, formatExpiry } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
@@ -25,29 +29,23 @@
type Filter = "active" | "all";
const stateLabels: Record<string, string> = {
active: "Открыт",
exhausted: "Лимит исчерпан",
expired: "Истёк",
revoked: "Отозван",
};
let items = $state<FileShare[]>([]);
let loading = $state(false);
let filter = $state<Filter>("active");
let fetchedAll = $state(false);
let expanded = $state<number | null>(null);
let token = 0;
const kept = new SvelteSet<number>();
const done = createFlash<number>();
const visible = $derived(
filter === "active"
? items.filter((item) => shareState(item) === "active")
? items.filter(
(item) => kept.has(item.id) || shareState(item) === "active"
)
: items
);
const activeCount = $derived(
fetchedAll
? items.filter((item) => shareState(item) === "active").length
: items.length
items.filter((item) => shareState(item) === "active").length
);
async function load() {
@@ -59,11 +57,11 @@
const rows = await listShares(activeOnly);
if (current === token) {
items = rows;
fetchedAll = !activeOnly;
kept.clear();
}
} catch {
if (current === token) {
toasts.error("Не удалось загрузить список ссылок");
toasts.error(t("shares.listFailed"));
}
} finally {
if (current === token) {
@@ -89,26 +87,22 @@
ui.closePanel();
}
async function copy(share: FileShare) {
await navigator.clipboard.writeText(shareUrl(share));
toasts.success("Ссылка скопирована");
}
async function revoke(share: FileShare) {
try {
replace(await revokeShare(share.id));
toasts.success("Доступ отозван");
kept.add(share.id);
done.show(share.id);
} catch {
toasts.error("Не удалось отозвать доступ");
toasts.error(t("shares.revokeFailed"));
}
}
async function reissue(share: FileShare) {
try {
replace(await reissueShare(share.id));
toasts.success("Выдана новая ссылка");
done.show(share.id);
} catch {
toasts.error("Не удалось перевыпустить ссылку");
toasts.error(t("shares.reissueFailed"));
}
}
@@ -117,7 +111,7 @@
await deleteShare(share.id);
items = items.filter((item) => item.id !== share.id);
} catch {
toasts.error("Не удалось удалить запись");
toasts.error(t("shares.forgetFailed"));
}
}
@@ -142,7 +136,7 @@
type="button"
class:selected={filter === "active"}
>
Открытые{activeCount ? ` · ${activeCount}` : ""}
{t("shares.tab.active")}{activeCount ? ` · ${activeCount}` : ""}
</button>
<button
class="tab"
@@ -152,11 +146,11 @@
type="button"
class:selected={filter === "all"}
>
Все
{t("shares.tab.all")}
</button>
</div>
<button
aria-label="Обновить"
aria-label={t("ui.refresh")}
class="reload"
onclick={() => load()}
type="button"
@@ -169,8 +163,8 @@
<div class="center"><Spinner /></div>
{:else if visible.length === 0}
<EmptyState
description="Откройте доступ к файлу из меню медиа"
title="Ничего не открыто"
description={t("shares.empty.hint")}
title={t("shares.empty.title")}
/>
{:else}
<ul class="list">
@@ -198,14 +192,14 @@
<span class="text">
<span class="name">{share.file_name}</span>
<span class="sub">
{stateLabels[state]}
{t(`shares.state.${state}`)}
· {formatDownloads(share)}
{#if share.file_size}
· {formatBytes(share.file_size)}
{/if}
</span>
<span class="sub">
{share.title ?? "Без чата"}
{share.title ?? t("shares.noChat")}
· {formatExpiry(share)}
</span>
</span>
@@ -215,12 +209,15 @@
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}>
Копировать ссылку
</ContextMenuItem>
<CopyItem
done={t("shares.copied")}
icon="copy"
label={t("shares.copy")}
text={() => shareUrl(share)}
/>
{#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}>
Перейти к сообщению
{t("ui.jumpToMessage")}
</ContextMenuItem>
{/if}
{#if state === "active"}
@@ -228,14 +225,14 @@
icon="link-broken"
onselect={() => revoke(share)}
>
Отозвать
{t("shares.revoke")}
</ContextMenuItem>
{:else}
<ContextMenuItem icon="replace" onselect={() => reissue(share)}>
Выдать новую ссылку
{t("shares.reissue")}
</ContextMenuItem>
<ContextMenuItem icon="delete" onselect={() => forget(share)}>
Убрать из списка
{t("shares.forget")}
</ContextMenuItem>
{/if}
{/snippet}
@@ -246,22 +243,31 @@
<ShareLinkField url={shareUrl(share)} />
{#if share.last_download_at}
<p class="last">
Последнее скачивание: {formatFull(share.last_download_at)}
{t("shares.lastDownload", {
date: formatFull(share.last_download_at),
})}
</p>
{/if}
<div class="actions">
{#if share.message_id !== null || share.peer_id !== null}
<button class="pill" onclick={() => jump(share)} type="button">
<Icon name="reply" size="1rem" />К сообщению
<Icon name="reply" size="1rem" />{t("shares.toMessage")}
</button>
{/if}
{#if state === "active"}
{#if done.current === share.id}
<span class="done">
<Icon name="check" size="1rem" />
{state === "active"
? t("shares.reissued")
: t("shares.accessRevoked")}
</span>
{:else if state === "active"}
<button
class="pill danger"
onclick={() => revoke(share)}
type="button"
>
<Icon name="link-broken" size="1rem" />Отозвать
<Icon name="link-broken" size="1rem" />{t("shares.revoke")}
</button>
{:else}
<button
@@ -269,7 +275,7 @@
onclick={() => reissue(share)}
type="button"
>
<Icon name="replace" size="1rem" />Новая ссылка
<Icon name="replace" size="1rem" />{t("shares.newLink")}
</button>
{/if}
</div>
@@ -279,9 +285,7 @@
{/each}
</ul>
<p class="footnote">
{visible.length}
{plural(visible.length, "ссылка", "ссылки", "ссылок")}
· превью-краулеры не списывают скачивания
{t("shares.footnote", { count: visible.length })}
</p>
{/if}
@@ -471,6 +475,18 @@
}
}
.done {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0;
border: 1px solid transparent;
font-size: 0.8125rem;
color: var(--color-text-green);
}
.footnote {
margin: 0;
padding: 0 1rem 1rem;
@@ -5,6 +5,7 @@
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state";
@@ -90,11 +91,11 @@
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Callback-данные" />
<EmptyState description={t("ui.noMessage")} title={t("social.callbacks")} />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет callback-кнопок" />
<EmptyState title={t("social.callbacks.empty")} />
{:else}
<ul class="list">
{#each items as item (item.position)}
@@ -106,7 +107,7 @@
</div>
{#if value}
<button
aria-label="Скопировать"
aria-label={t("ui.copy")}
class="copy"
onclick={() => copy(item.position, value)}
type="button"
@@ -6,6 +6,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state";
@@ -56,11 +57,11 @@
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Ссылки" />
<EmptyState description={t("ui.noMessage")} title={t("social.links")} />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет ссылок" />
<EmptyState title={t("social.links.empty")} />
{:else}
<ul class="list">
{#each items as item (item.position)}
@@ -8,6 +8,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { peerColorIndex, 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";
import { ui } from "#lib/stores/ui.svelte.js";
@@ -65,11 +66,11 @@
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Реакции" />
<EmptyState description={t("ui.noMessage")} title={t("social.reactions")} />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет реакций" />
<EmptyState title={t("social.reactions.empty")} />
{:else}
<div class="groups">
{#each groups as [reaction, reactors] (reaction)}
@@ -104,7 +105,9 @@
>
<span class="time">
{#if reactor.removed_at}
снято · {formatFull(reactor.removed_at)}
{t("social.reactions.removed", {
date: formatFull(reactor.removed_at),
})}
{:else}
{formatFull(reactor.added_at)}
{/if}
@@ -16,8 +16,9 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { peerName } from "#lib/format/peer.js";
import { poster } from "#lib/media/poster.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Group {
@@ -45,6 +46,7 @@
let viewerItems = $state<StoryView[]>([]);
let viewerPeerId = $state(0);
let backfilling = $state<number | null>(null);
const started = createFlash<number>();
async function load() {
const current = token;
@@ -89,7 +91,7 @@
return {
hasAvatar: Boolean(chat?.has_avatar),
kind: "chat" as const,
name: chat?.title ?? `Chat ${peerId}`,
name: chat?.title ?? t("ui.chat", { id: peerId }),
peerId,
stories: peerStories,
};
@@ -150,9 +152,9 @@
backfilling = peerId;
try {
await enqueueStoriesBackfill(peerId);
toasts.success("Загружаем старые сторис");
started.show(peerId);
} catch {
toasts.error("Не удалось запустить загрузку сторис");
toasts.error(t("stories.backfillFailed"));
} finally {
backfilling = null;
}
@@ -174,10 +176,14 @@
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет сторис" />
<EmptyState title={t("stories.empty")} />
{/if}
{:else}
{#each groups as group (group.peerId)}
{@const fresh = started.current === group.peerId}
{@const backfillLabel = fresh
? t("stories.backfillStarted")
: t("stories.loadOlderStories")}
<section class="group">
<header class="group-head">
<button
@@ -200,14 +206,15 @@
</span>
</button>
<Button
aria-label="Загрузить старые сторис"
aria-label={backfillLabel}
loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)}
round
smaller
title={backfillLabel}
variant="translucent"
>
<Icon name="cloud-download" />
<Icon name={fresh ? "check" : "cloud-download"} />
</Button>
</header>
{#if expanded[group.peerId]}
@@ -295,62 +302,4 @@
gap: 0.125rem;
padding: 0 0.125rem 0.125rem;
}
.tile {
position: relative;
aspect-ratio: 9 / 16;
padding: 0;
border: 0;
cursor: pointer;
background-color: var(--color-background-secondary);
&.expired {
opacity: 0.55;
}
}
.tile img,
.tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
}
.ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-secondary);
}
.badge {
position: absolute;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
color: var(--color-white);
background-color: rgb(0 0 0 / 45%);
}
.views {
bottom: 0.25rem;
left: 0.25rem;
}
</style>
@@ -9,8 +9,9 @@
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { poster } from "#lib/media/poster.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { page } from "$app/state";
@@ -27,6 +28,7 @@
);
let backfilling = $state(false);
const started = createFlash<number>();
let items = $state<StoryView[]>([]);
let loading = $state(false);
@@ -109,9 +111,9 @@
backfilling = true;
try {
await enqueueStoriesBackfill(id);
toasts.success("Загружаем старые сторис");
started.show(id);
} catch {
toasts.error("Не удалось запустить загрузку сторис");
toasts.error(t("stories.backfillFailed"));
} finally {
backfilling = false;
}
@@ -127,7 +129,7 @@
</script>
{#if peerId === null}
<EmptyState description="Откройте чат" title="Сторис" />
<EmptyState description={t("stories.openChat")} title={t("stories.title")} />
{:else}
<div class="toolbar">
<Button
@@ -137,10 +139,14 @@
smaller
variant="secondary"
>
<Icon name="cloud-download" />Загрузить старые
{#if started.current === peerId}
<Icon name="check" />{t("stories.backfillStarted")}
{:else}
<Icon name="cloud-download" />{t("stories.loadOlder")}
{/if}
</Button>
<Button
aria-label="Обновить"
aria-label={t("ui.refresh")}
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
round
smaller
@@ -155,8 +161,8 @@
<div class="center"><Spinner /></div>
{:else}
<EmptyState
description="Нажмите «Загрузить старые», чтобы забрать архив"
title="Нет сторис"
description={t("stories.emptyHint")}
title={t("stories.empty")}
/>
{/if}
{:else}
@@ -177,7 +183,7 @@
onclick={() => peerId !== null && loadMore(peerId)}
type="button"
>
{loading ? "Загрузка…" : "Показать ещё"}
{loading ? t("ui.loading") : t("ui.showMore")}
</button>
{/if}
@@ -212,69 +218,6 @@
padding: 0.125rem;
}
.tile {
position: relative;
aspect-ratio: 9 / 16;
padding: 0;
border: 0;
cursor: pointer;
background-color: var(--color-background-secondary);
&.expired {
opacity: 0.55;
}
}
.tile img,
.tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
}
.ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-secondary);
}
.badge {
position: absolute;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
color: var(--color-white);
background-color: rgb(0 0 0 / 45%);
}
.pin {
top: 0.25rem;
left: 0.25rem;
}
.views {
bottom: 0.25rem;
left: 0.25rem;
}
.more {
width: calc(100% - 1rem);
margin: 0.5rem;
@@ -5,6 +5,7 @@
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,14 +21,16 @@
const { story, preview, onopen }: Props = $props();
const label = $derived(
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
story.date
? t("stories.from", { date: formatFull(story.date) })
: t("stories.undated")
);
async function save() {
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
toasts.error(t("stories.downloadFailed"));
}
}
@@ -78,25 +81,26 @@
{/if}
{#if story.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{story.views}
<Icon name="eye" size="0.875rem" />
{story.views.toLocaleString(locale.tag)}
</span>
{/if}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
Открыть
{t("ui.open")}
</ContextMenuItem>
{#if story.downloaded}
<ContextMenuItem icon="download" onselect={save}>
Скачать
{t("ui.download")}
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
{t("ui.shareLink")}
</ContextMenuItem>
{/if}
<ContextMenuItem icon="info" onselect={openAuthor}>
Профиль автора
{t("stories.author")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
@@ -9,6 +9,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -121,7 +122,7 @@
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
toasts.error(t("stories.downloadFailed"));
} finally {
saving = false;
}
@@ -133,7 +134,9 @@
}
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
story.date
? t("stories.from", { date: formatFull(story.date) })
: t("stories.undated")
);
}
@@ -185,7 +188,9 @@
<Dialog.Portal>
<Dialog.Overlay class="story-overlay" />
<Dialog.Content class="story-content {held ? "held" : ""}">
<Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
<Dialog.Title class="story-a11y-title">
{t("stories.title")}
</Dialog.Title>
<div class="bars">
{#each items as item, i (item.story_id)}
<div class="bar">
@@ -214,7 +219,7 @@
<div class="head-actions">
{#if isVideo}
<button
aria-label={muted ? "Включить звук" : "Выключить звук"}
aria-label={muted ? t("stories.unmute") : t("stories.mute")}
class="round"
onclick={() => {
muted = !muted;
@@ -231,7 +236,7 @@
})}
<button
{...props}
aria-label="Скачать сторис"
aria-label={t("stories.download")}
class="round"
onclick={save}
type="button"
@@ -241,15 +246,15 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать
{t("ui.download")}
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
{t("ui.shareLink")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
<Dialog.Close aria-label="Закрыть" class="round">
<Dialog.Close aria-label={t("common.close")} class="round">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
@@ -283,7 +288,7 @@
src={url}
>
{:else}
<p class="missing">Медиа недоступно</p>
<p class="missing">{t("stories.unavailable")}</p>
{/if}
</div>
@@ -296,12 +301,13 @@
{#if story?.views}
<span class="view-count">
<Icon name="eye" size="1rem" />{story.views}
<Icon name="eye" size="1rem" />
{story.views.toLocaleString(locale.tag)}
</span>
{/if}
<button
aria-label="Назад"
aria-label={t("ui.previous")}
class="tap prev"
onclick={() => onTap(-1)}
onpointercancel={releaseHold}
@@ -309,7 +315,7 @@
type="button"
></button>
<button
aria-label="Вперёд"
aria-label={t("ui.next")}
class="tap next"
onclick={() => onTap(1)}
onpointercancel={releaseHold}
@@ -318,7 +324,7 @@
></button>
{#if held}
<span class="hold-hint">Пауза</span>
<span class="hold-hint">{t("stories.paused")}</span>
{/if}
</Dialog.Content>
</Dialog.Portal>
+3 -1
View File
@@ -33,13 +33,14 @@
loading = false,
type = "button",
disabled = false,
class: className,
children,
...rest
}: Props = $props();
</script>
<button
class="Button {variant}"
class={["Button", variant, className]}
{disabled}
{type}
class:disabled
@@ -65,6 +66,7 @@
overflow: hidden;
display: flex;
flex-shrink: 0;
gap: 0.375rem;
align-items: center;
justify-content: center;
@@ -1,7 +1,11 @@
<script lang="ts" module>
import { createContext } from "svelte";
type PointerHandler = (event: PointerEvent) => void;
const claimed = new WeakSet<Event>();
export const [menuCloser, provideMenuCloser] = createContext<() => void>();
</script>
<script lang="ts">
@@ -18,6 +22,10 @@
let open = $state(false);
let suppressClick = false;
provideMenuCloser(() => {
open = false;
});
function triggerProps(props: Record<string, unknown>) {
const onpointerdown = props.onpointerdown as PointerHandler;
const onpointerup = props.onpointerup as PointerHandler;
@@ -55,7 +63,12 @@
{/snippet}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="bg-menu-content">
<ContextMenu.Content
align="start"
class="bg-menu-content"
collisionPadding={8}
side="bottom"
>
{@render menu()}
</ContextMenu.Content>
</ContextMenu.Portal>
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more