Compare commits

..
21 Commits
Author SHA1 Message Date
hh 0399145791 style(frontend): soften the document glyph nudge to a hairline 2026-08-13 04:13:03 +02:00
hh 0b8e90159f style(frontend): optically nudge the document glyph in file chips 2026-08-13 04:07:34 +02:00
hh 995322b17a fix(frontend): readable links and file chips on own bubbles 2026-08-13 04:06:44 +02:00
hh 6ae28cb321 fix(frontend): render document albums as file rows instead of broken images 2026-08-13 03:59:01 +02:00
hh e833de245a fix(api,frontend): render files by name without fetching, previewable share links 2026-08-13 03:49:29 +02:00
hh ef739838a5 fix(migrations): lift timescale decompression limit when dropping scheduled dupes 2026-08-13 03:30:07 +02:00
hh 3e08698b62 feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes 2026-08-13 03:17:36 +02:00
hh 683b9a31a3 fix(frontend): restore outside-click dismiss, collapse story groups by default 2026-08-06 15:28:10 +02:00
hh 004fd56f2c feat(frontend): open sidebar media on click, unbreak long-press menus 2026-08-06 15:17:25 +02:00
hh 12cc7d57e3 feat(userbot,api,frontend): backfill stories and unbreak all-stories list 2026-08-06 14:08:10 +02:00
hh 1e143c7573 fix(frontend): unbreak round video ring and playback in firefox 2026-08-06 13:52:27 +02:00
hh ee63f8b783 feat(api,userbot,frontend): fetch only new messages in backfill by default 2026-08-06 12:30:37 +02:00
hh 6b6edc9a0d fix(frontend): paint video poster frames, clip round videos to circle 2026-08-06 12:29:37 +02:00
hh 525ce024bc perf(api,frontend): index hot queries, paginate chats, fix realtime 2026-08-06 01:52:41 +02:00
hh 9d767d2531 chore(frontend): sync bun lockfile 2026-08-06 01:07:52 +02:00
hh 18220407af fix(make): enable db and migrate profiles in migrate target 2026-08-06 01:07:51 +02:00
hh 1898a51a9d feat(api,userbot,frontend): rename session device from the web ui 2026-08-06 00:51:58 +02:00
hh b1848a6620 feat(caddy): move routes into site.caddy 2026-08-06 00:50:29 +02:00
hh e887dfc5ce feat(api,frontend): add qr code login 2026-08-06 00:23:38 +02:00
hh 9c265af3d3 feat(api,userbot,frontend): add accounts from the web ui and isolate per-account settings 2026-08-05 23:49:52 +02:00
hh 92fd20137e feat(api,userbot,frontend): search peers without chats and start tracking them 2026-08-05 23:46:54 +02:00
118 changed files with 7165 additions and 903 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ deploy:
$(MAKE) rebuild
migrate:
docker compose run --rm migrator $(filter-out $@,$(MAKECMDGOALS))
docker compose --profile db --profile migrate run --rm migrator $(filter-out $@,$(MAKECMDGOALS))
session-create:
cd backend && uv run python scripts/session/create.py
@@ -0,0 +1,24 @@
"""account device model
Revision ID: b9e4d1a70c26
Revises: e7b4c2a9f861
Create Date: 2026-08-06 01:20:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b9e4d1a70c26"
down_revision: str | None = "e7b4c2a9f861"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE accounts ADD COLUMN device_model text")
def downgrade() -> None:
op.execute("ALTER TABLE accounts DROP COLUMN device_model")
@@ -0,0 +1,41 @@
"""hot path indexes
Revision ID: c1f6b3d84a92
Revises: b9e4d1a70c26
Create Date: 2026-08-06 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c1f6b3d84a92"
down_revision: str | None = "b9e4d1a70c26"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"CREATE INDEX ix_messages_chat_date ON messages "
"(account_id, chat_id, date DESC, message_id DESC)"
)
op.execute("CREATE INDEX ix_avatars_owner ON avatars (account_id, owner_id)")
op.execute("CREATE INDEX ix_media_message ON media (account_id, message_id)")
op.execute(
"CREATE INDEX ix_chat_history_chat_ts ON chat_history "
"(account_id, chat_id, ts DESC)"
)
op.execute(
"CREATE INDEX ix_read_receipts_chat ON read_receipts "
"(account_id, chat_id, kind, message_id DESC)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_read_receipts_chat")
op.execute("DROP INDEX IF EXISTS ix_chat_history_chat_ts")
op.execute("DROP INDEX IF EXISTS ix_media_message")
op.execute("DROP INDEX IF EXISTS ix_avatars_owner")
op.execute("DROP INDEX IF EXISTS ix_messages_chat_date")
@@ -0,0 +1,119 @@
"""chat stats
Revision ID: d4a7e2b91f38
Revises: c1f6b3d84a92
Create Date: 2026-08-06 12:30:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d4a7e2b91f38"
down_revision: str | None = "c1f6b3d84a92"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_APPLY = """
CREATE 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)
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)
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;
ELSE
UPDATE chat_stats
SET last_text = NEW.text, last_sender_id = NEW.sender_id
WHERE account_id = NEW.account_id
AND chat_id = NEW.chat_id
AND last_message_id = NEW.message_id;
END IF;
RETURN NULL;
END;
$$
"""
_NEWER = """
CREATE FUNCTION chat_stats_newer(current chat_stats, incoming chat_stats)
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
SELECT incoming.last_date IS NOT NULL
AND (current.last_date IS NULL
OR (incoming.last_date, incoming.last_message_id)
> (current.last_date, current.last_message_id))
$$
"""
_BACKFILL = """
INSERT INTO chat_stats (account_id, chat_id, message_count, last_date,
last_message_id, last_text, last_sender_id)
SELECT agg.account_id, agg.chat_id, agg.message_count,
last.date, last.message_id, last.text, last.sender_id
FROM (
SELECT account_id, chat_id, count(*) AS message_count
FROM messages GROUP BY account_id, chat_id
) agg
LEFT JOIN LATERAL (
SELECT date, message_id, text, sender_id FROM messages m
WHERE m.account_id = agg.account_id AND m.chat_id = agg.chat_id
AND m.date <= now() + interval '1 day'
ORDER BY m.date DESC, m.message_id DESC LIMIT 1
) last ON true
"""
def upgrade() -> None:
op.execute(
"CREATE TABLE chat_stats ("
"account_id integer NOT NULL, "
"chat_id bigint NOT NULL, "
"message_count bigint NOT NULL DEFAULT 0, "
"last_date timestamptz, "
"last_message_id bigint, "
"last_text text, "
"last_sender_id bigint, "
"PRIMARY KEY (account_id, chat_id))"
)
op.execute(_BACKFILL)
op.execute(
"CREATE INDEX ix_chat_stats_recent ON chat_stats "
"(account_id, last_date DESC, chat_id DESC)"
)
op.execute(_NEWER)
op.execute(_APPLY)
op.execute(
"CREATE TRIGGER messages_chat_stats_insert AFTER INSERT ON messages "
"FOR EACH ROW EXECUTE FUNCTION chat_stats_apply()"
)
op.execute(
"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) "
"EXECUTE FUNCTION chat_stats_apply()"
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_insert ON messages")
op.execute("DROP FUNCTION IF EXISTS chat_stats_apply()")
op.execute("DROP FUNCTION IF EXISTS chat_stats_newer(chat_stats, chat_stats)")
op.execute("DROP TABLE IF EXISTS chat_stats")
@@ -0,0 +1,75 @@
"""drop scheduled message duplicates
Revision ID: e1c7a4b62d90
Revises: d4a7e2b91f38
Create Date: 2026-08-13 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "e1c7a4b62d90"
down_revision: str | None = "d4a7e2b91f38"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_STAGE = """
CREATE TEMP TABLE scheduled_keys ON COMMIT DROP AS
SELECT DISTINCT s.account_id, s.chat_id, s.message_id
FROM messages s
WHERE s.raw->>'scheduled' = 'true'
AND NOT EXISTS (
SELECT 1 FROM messages r
WHERE r.account_id = s.account_id AND r.chat_id = s.chat_id
AND r.message_id = s.message_id
AND r.raw->>'scheduled' IS DISTINCT FROM 'true'
)
"""
_DELETE_CHILD = """
DELETE FROM {table} t
USING scheduled_keys k
WHERE t.account_id = k.account_id AND t.chat_id = k.chat_id
AND t.message_id = k.message_id
"""
_DELETE_MESSAGES = """
DELETE FROM messages m
USING scheduled_keys k
WHERE m.account_id = k.account_id AND m.chat_id = k.chat_id
AND m.message_id = k.message_id AND m.raw->>'scheduled' = 'true'
"""
_RECOUNT = """
UPDATE chat_stats cs
SET message_count = fresh.message_count
FROM (
SELECT k.account_id, k.chat_id,
(SELECT count(*) FROM messages m
WHERE m.account_id = k.account_id AND m.chat_id = k.chat_id)
AS message_count
FROM (SELECT DISTINCT account_id, chat_id FROM scheduled_keys) k
) fresh
WHERE cs.account_id = fresh.account_id AND cs.chat_id = fresh.chat_id
"""
_CHILD_TABLES = ("media", "media_versions", "message_versions", "links", "callbacks")
_ALLOW_DECOMPRESSION = (
"SET LOCAL timescaledb.max_tuples_decompressed_per_dml_transaction = 0"
)
def upgrade() -> None:
op.execute(_ALLOW_DECOMPRESSION)
op.execute(_STAGE)
for table in _CHILD_TABLES:
op.execute(_DELETE_CHILD.format(table=table))
op.execute(_DELETE_MESSAGES)
op.execute(_RECOUNT)
def downgrade() -> None:
pass
@@ -0,0 +1,43 @@
"""per-account policy defaults
Revision ID: e7b4c2a9f861
Revises: d5f9b2c8e3a1
Create Date: 2026-08-05 23:10:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "e7b4c2a9f861"
down_revision: str | None = "d5f9b2c8e3a1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_SCOPE_KEY = "capture_policy_account_id_scope_type_scope_id_key"
def upgrade() -> None:
op.execute("DROP INDEX ix_capture_policy_default")
op.execute(f"ALTER TABLE capture_policy DROP CONSTRAINT {_SCOPE_KEY}")
op.execute(
"CREATE UNIQUE INDEX ix_capture_policy_scope ON capture_policy "
"(account_id, scope_type, scope_id) NULLS NOT DISTINCT"
)
def downgrade() -> None:
op.execute(
"DELETE FROM capture_policy WHERE account_id IS NOT NULL "
"AND scope_type LIKE 'default_%'"
)
op.execute("DROP INDEX ix_capture_policy_scope")
op.execute(
f"ALTER TABLE capture_policy ADD CONSTRAINT {_SCOPE_KEY} "
"UNIQUE (account_id, scope_type, scope_id)"
)
op.execute(
"CREATE UNIQUE INDEX ix_capture_policy_default "
"ON capture_policy (scope_type) WHERE scope_type LIKE 'default_%'"
)
@@ -0,0 +1,73 @@
"""file shares
Revision ID: f2b8d3c9a51e
Revises: e1c7a4b62d90
Create Date: 2026-08-13 10:30:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "f2b8d3c9a51e"
down_revision: str | None = "e1c7a4b62d90"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_SHARES = """
CREATE TABLE file_shares (
id serial PRIMARY KEY,
account_id integer NOT NULL,
token text NOT NULL UNIQUE,
kind text NOT NULL,
storage_key text NOT NULL,
file_name text NOT NULL,
mime text,
file_size bigint,
title text,
chat_id bigint,
message_id bigint,
peer_id bigint,
story_id bigint,
expires_at timestamptz,
max_downloads integer,
download_count integer NOT NULL DEFAULT 0,
last_download_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
_HITS = """
CREATE TABLE file_share_hits (
id bigserial PRIMARY KEY,
share_id integer NOT NULL REFERENCES file_shares (id) ON DELETE CASCADE,
ts timestamptz NOT NULL DEFAULT now(),
method text NOT NULL,
ip text,
user_agent text,
counted boolean NOT NULL DEFAULT false
)
"""
def upgrade() -> None:
op.execute(_SHARES)
op.execute(
"CREATE INDEX ix_file_shares_account ON file_shares "
"(account_id, created_at DESC)"
)
op.execute(
"CREATE INDEX ix_file_shares_subject ON file_shares "
"(account_id, storage_key) WHERE revoked_at IS NULL"
)
op.execute(_HITS)
op.execute(
"CREATE INDEX ix_file_share_hits_share ON file_share_hits (share_id, ts DESC)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS file_share_hits")
op.execute("DROP TABLE IF EXISTS file_shares")
+12 -2
View File
@@ -20,7 +20,9 @@ from api.routers import (
backfill,
chats,
custom_emoji,
discover,
events,
files,
folders,
media,
peers,
@@ -28,11 +30,13 @@ from api.routers import (
presence,
profile,
search,
shares,
social,
stories,
watches,
)
from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env
if env.auth.token is None:
@@ -83,8 +87,11 @@ app.include_router(stories.router)
app.include_router(profile.router)
app.include_router(events.router)
app.include_router(peers.router)
app.include_router(discover.router)
app.include_router(annotations.router)
app.include_router(watches.router)
app.include_router(shares.router)
app.include_router(files.router)
app.mount("/mcp", mcp_app)
@@ -103,8 +110,11 @@ if _spa_dir.is_dir():
async def serve_spa(spa_path: str) -> FileResponse:
candidate = (_spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file():
return FileResponse(candidate)
return FileResponse(_spa_index)
immutable = spa_path.startswith("_app/immutable/")
return FileResponse(
candidate, headers=IMMUTABLE_HEADERS if immutable else DAY_HEADERS
)
return FileResponse(_spa_index, headers=NO_STORE_HEADERS)
app.add_middleware(BearerAuthMiddleware, token=_token)
+183
View File
@@ -0,0 +1,183 @@
import asyncio
import contextlib
import secrets
import time
from dataclasses import dataclass, field
from pathlib import Path
from pyrogram.errors import AuthTokenExpired, SessionPasswordNeeded
from pyrogram.qrlogin import QRLogin
from pyrogram.types import User
from userbot import PyroClient
from utils.env import env
LOGIN_TTL_SECONDS = 900
QR_POLL_SECONDS = 25
PENDING_DIRNAME = "pending"
class LoginError(Exception):
pass
@dataclass
class QrState:
url: str
user: User | None = None
password_needed: bool = False
error: str | None = None
changed: asyncio.Event = field(default_factory=asyncio.Event)
@property
def settled(self) -> bool:
return self.user is not None or self.password_needed or self.error is not None
@dataclass
class PendingLogin:
client: PyroClient
started_at: float
phone: str = ""
phone_code_hash: str = ""
qr: QrState | None = None
watcher: asyncio.Task[None] | None = None
def _sessions_dir() -> Path:
path = Path(env.tg.sessions_dir)
path.mkdir(parents=True, exist_ok=True)
return path
def _pending_dir() -> Path:
path = _sessions_dir() / PENDING_DIRNAME
path.mkdir(parents=True, exist_ok=True)
return path
async def _stop_watcher(login: PendingLogin) -> None:
if login.watcher is None or login.watcher.done():
return
login.watcher.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await login.watcher
async def _watch_qr(qr: QRLogin, state: QrState) -> None:
while not state.settled:
try:
try:
state.user = await qr.wait()
except (TimeoutError, AuthTokenExpired):
await qr.recreate()
state.url = qr.url
except SessionPasswordNeeded:
state.password_needed = True
except Exception as exc:
state.error = str(exc)
state.changed.set()
class LoginManager:
def __init__(self) -> None:
self._logins: dict[str, PendingLogin] = {}
def _get(self, login_id: str) -> PendingLogin:
login = self._logins.get(login_id)
if login is None:
msg = "Сессия входа истекла, начните заново"
raise LoginError(msg)
return login
async def _connect(self) -> tuple[str, PyroClient]:
await self._sweep()
login_id = secrets.token_hex(8)
client = PyroClient(login_id, workdir=str(_pending_dir()), load_handlers=False)
await client.connect()
return login_id, client
async def start(self, phone: str) -> str:
login_id, client = await self._connect()
try:
sent = await client.send_code(phone)
except Exception:
await self._discard(login_id, client)
raise
self._logins[login_id] = PendingLogin(
client, time.monotonic(), phone=phone, phone_code_hash=sent.phone_code_hash
)
return login_id
async def start_qr(self) -> tuple[str, str]:
login_id, client = await self._connect()
qr = QRLogin(client)
try:
await qr.recreate()
except Exception:
await self._discard(login_id, client)
raise
state = QrState(qr.url)
self._logins[login_id] = PendingLogin(
client,
time.monotonic(),
qr=state,
watcher=asyncio.create_task(_watch_qr(qr, state)),
)
return login_id, state.url
async def wait_qr(self, login_id: str) -> QrState:
login = self._get(login_id)
if login.qr is None:
msg = "Этот вход начат по номеру телефона"
raise LoginError(msg)
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(QR_POLL_SECONDS):
await login.qr.changed.wait()
login.qr.changed.clear()
return login.qr
async def submit_code(self, login_id: str, code: str) -> User | None:
login = self._get(login_id)
try:
user = await login.client.sign_in(login.phone, login.phone_code_hash, code)
except SessionPasswordNeeded:
return None
if not isinstance(user, User):
await self.cancel(login_id)
msg = "Этот номер не зарегистрирован в Telegram"
raise LoginError(msg)
return user
async def submit_password(self, login_id: str, password: str) -> User:
return await self._get(login_id).client.check_password(password)
async def finalize(self, login_id: str, session_name: str) -> None:
login = self._logins.pop(login_id)
await _stop_watcher(login)
await login.client.disconnect()
source = _pending_dir() / f"{login_id}.session"
source.replace(_sessions_dir() / f"{session_name}.session")
async def cancel(self, login_id: str) -> None:
login = self._logins.pop(login_id, None)
if login is not None:
await _stop_watcher(login)
await self._discard(login_id, login.client)
async def _discard(self, login_id: str, client: PyroClient) -> None:
with contextlib.suppress(Exception):
await client.disconnect()
(_pending_dir() / f"{login_id}.session").unlink(missing_ok=True)
async def _sweep(self) -> None:
now = time.monotonic()
for login_id, login in list(self._logins.items()):
if now - login.started_at > LOGIN_TTL_SECONDS:
await self.cancel(login_id)
for path in _pending_dir().glob("*.session"):
if path.stem not in self._logins:
path.unlink(missing_ok=True)
login_manager = LoginManager()
+6 -1
View File
@@ -65,11 +65,16 @@ class EventHub:
return
account_id = event.get("account_id")
chat_id = event.get("chat_id")
scoped = event.get("kind") == "presence"
targets = [
sub
for sub in self._subscribers
if sub.account_id == account_id
and (sub.chat_id is None or sub.chat_id == chat_id)
and (
sub.chat_id == chat_id
if scoped
else sub.chat_id is None or sub.chat_id == chat_id
)
]
if not targets:
return
+138 -1
View File
@@ -1,13 +1,150 @@
from collections.abc import Coroutine
from typing import Any, Literal
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from pyrogram.errors import FloodWait, RPCError
from pyrogram.types import User
from api.login import LoginError, login_manager
from userbot import DEVICE_MODEL_LIMIT
from utils.read import accounts
from utils.read.models import AccountView
router = APIRouter(prefix="/api", tags=["accounts"], route_class=DishkaRoute)
_ERROR_MESSAGES = {
"PhoneNumberInvalid": "Неверный номер телефона",
"PhoneNumberBanned": "Номер заблокирован в Telegram",
"PhoneCodeInvalid": "Неверный код",
"PhoneCodeExpired": "Код истёк, запросите новый",
"PasswordHashInvalid": "Неверный пароль",
}
class PhoneRequest(BaseModel):
phone: str
class CodeRequest(BaseModel):
code: str
class PasswordRequest(BaseModel):
password: str
class DeviceModelRequest(BaseModel):
device_model: str
class LoginState(BaseModel):
login_id: str
stage: Literal["code", "qr", "password", "done"]
qr_url: str | None = None
account: AccountView | None = None
async def _guard[T](coro: Coroutine[Any, Any, T]) -> T:
try:
return await coro
except LoginError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
except FloodWait as exc:
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
f"Слишком много попыток, подождите {exc.value} с",
) from exc
except RPCError as exc:
detail = _ERROR_MESSAGES.get(type(exc).__name__, str(exc))
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail) from exc
async def _complete(pool: asyncpg.Pool, login_id: str, user: User) -> LoginState:
session_name = str(user.id)
account_id = await accounts.sync_account(pool, user, session_name)
await login_manager.finalize(login_id, session_name)
await accounts.notify_accounts_changed(pool)
return LoginState(
login_id=login_id,
stage="done",
account=await accounts.get_account(pool, account_id),
)
@router.get("/accounts")
async def list_accounts(pool: FromDishka[asyncpg.Pool]) -> list[AccountView]:
return await accounts.list_accounts(pool)
@router.post("/accounts/login")
async def start_login(body: PhoneRequest) -> LoginState:
login_id = await _guard(login_manager.start(body.phone))
return LoginState(login_id=login_id, stage="code")
@router.post("/accounts/login/qr")
async def start_qr_login() -> LoginState:
login_id, url = await _guard(login_manager.start_qr())
return LoginState(login_id=login_id, stage="qr", qr_url=url)
@router.get("/accounts/login/{login_id}/qr")
async def poll_qr_login(login_id: str, pool: FromDishka[asyncpg.Pool]) -> LoginState:
state = await _guard(login_manager.wait_qr(login_id))
if state.user is not None:
return await _complete(pool, login_id, state.user)
if state.password_needed:
return LoginState(login_id=login_id, stage="password")
if state.error is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, state.error)
return LoginState(login_id=login_id, stage="qr", qr_url=state.url)
@router.post("/accounts/login/{login_id}/code")
async def submit_code(
login_id: str, body: CodeRequest, pool: FromDishka[asyncpg.Pool]
) -> LoginState:
user = await _guard(login_manager.submit_code(login_id, body.code))
if user is None:
return LoginState(login_id=login_id, stage="password")
return await _complete(pool, login_id, user)
@router.post("/accounts/login/{login_id}/password")
async def submit_password(
login_id: str, body: PasswordRequest, pool: FromDishka[asyncpg.Pool]
) -> LoginState:
user = await _guard(login_manager.submit_password(login_id, body.password))
return await _complete(pool, login_id, user)
@router.delete("/accounts/login/{login_id}", status_code=status.HTTP_204_NO_CONTENT)
async def cancel_login(login_id: str) -> None:
await login_manager.cancel(login_id)
@router.patch("/accounts/{account_id}/device")
async def rename_device(
account_id: int, body: DeviceModelRequest, pool: FromDishka[asyncpg.Pool]
) -> AccountView:
device_model = " ".join(body.device_model.split())
if not device_model or len(device_model) > DEVICE_MODEL_LIMIT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Имя устройства: от 1 до {DEVICE_MODEL_LIMIT} символов",
)
account = await accounts.set_device_model(pool, account_id, device_model)
if account is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Аккаунт не найден")
await accounts.notify_accounts_changed(pool)
return account
@router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
async def logout_account(account_id: int, pool: FromDishka[asyncpg.Pool]) -> None:
if await accounts.deactivate_account(pool, account_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Аккаунт не найден")
await accounts.notify_accounts_changed(pool)
+4 -1
View File
@@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.cache import IMMUTABLE_HEADERS, SHORT_HEADERS
from utils.jobs import enqueue
from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar
from utils.read.models import AvatarHistoryView
@@ -52,5 +53,7 @@ async def serve_avatar(
)
raise HTTPException(status_code=409, detail="avatar not downloaded; fetching")
return FileResponse(
storage.url(avatar.storage_key), media_type=avatar.mime or "image/jpeg"
storage.url(avatar.storage_key),
media_type=avatar.mime or "image/jpeg",
headers=IMMUTABLE_HEADERS if unique_id is not None else SHORT_HEADERS,
)
+17 -1
View File
@@ -16,6 +16,12 @@ class BackfillRequest(BaseModel):
account_id: int
chat_id: int
media: bool = False
full: bool = False
class StoriesBackfillRequest(BaseModel):
account_id: int
peer_id: int
class FetchMediaRequest(BaseModel):
@@ -70,7 +76,17 @@ async def enqueue_backfill(
pool,
body.account_id,
"backfill",
{"chat_id": body.chat_id, "media": body.media},
{"chat_id": body.chat_id, "media": body.media, "full": body.full},
)
return EnqueueResponse(job_id=job_id)
@router.post("/stories/backfill", status_code=201)
async def enqueue_stories_backfill(
pool: FromDishka[asyncpg.Pool], body: StoriesBackfillRequest
) -> EnqueueResponse:
job_id = await enqueue(
pool, body.account_id, "backfill_stories", {"peer_id": body.peer_id}
)
return EnqueueResponse(job_id=job_id)
+18 -1
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Query
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.policy import repository
from utils.read import chats
from utils.read.models import (
DEFAULT_LIMIT,
@@ -35,8 +36,24 @@ async def list_chats(
account_id: AccountId,
limit: Limit = DEFAULT_LIMIT,
offset: Offset = 0,
folder_id: Annotated[int | None, Query()] = None,
search: Annotated[str | None, Query()] = None,
) -> list[ChatListItem]:
return await chats.list_chats(pool, account_id, Page(limit=limit, offset=offset))
folder = (
await repository.get_folder(pool, account_id, folder_id)
if folder_id is not None
else None
)
return await chats.list_chats(
pool, account_id, Page(limit=limit, offset=offset), folder=folder, search=search
)
@router.get("/chats/{chat_id}")
async def get_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
) -> ChatListItem | None:
return await chats.get_chat(pool, account_id, chat_id)
@router.get("/chats/{chat_id}/messages")
+119
View File
@@ -0,0 +1,119 @@
import asyncio
import json
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from pydantic import BaseModel
from api.routers.policy import POLICY_CHANGED_CHANNEL
from utils.jobs import enqueue
from utils.policy import repository as policy_repository
from utils.policy.defaults import TRACKING
from utils.policy.models import ScopeType
from utils.read import discover
from utils.read.models import DiscoverItem
router = APIRouter(prefix="/api", tags=["discover"], route_class=DishkaRoute)
DEFAULT_LIMIT = 30
REMOTE_TIMEOUT_SECONDS = 20.0
POLL_INTERVAL_SECONDS = 0.2
FINISHED = ("done", "failed", "canceled")
_CHAT_POLICY_ID = """
SELECT id FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
"""
AccountId = Annotated[int, Query()]
class TrackRequest(BaseModel):
account_id: int
backfill: bool = True
class SyncContactsRequest(BaseModel):
account_id: int
async def _remote_ids(
pool: asyncpg.Pool, account_id: int, query: str, limit: int
) -> list[int]:
job_id = await enqueue(
pool, account_id, "search_peers", {"query": query, "limit": limit}
)
loop = asyncio.get_running_loop()
deadline = loop.time() + REMOTE_TIMEOUT_SECONDS
while loop.time() < deadline:
await asyncio.sleep(POLL_INTERVAL_SECONDS)
row = await pool.fetchrow(
"SELECT status, progress FROM jobs WHERE id = $1", job_id
)
if row is not None and row["status"] in FINISHED:
await pool.execute("DELETE FROM jobs WHERE id = $1", job_id)
return json.loads(row["progress"]).get("ids", [])
return []
@router.get("/discover")
async def discover_peers(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
query: Annotated[str, Query()] = "",
remote: Annotated[bool, Query()] = False,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
) -> list[DiscoverItem]:
if not query.strip():
return []
items = await discover.search(pool, account_id, query, limit)
if not remote:
return items
known = {item.chat_id for item in items}
ids = await _remote_ids(pool, account_id, query, limit)
extra = await discover.by_ids(
pool, account_id, [chat_id for chat_id in ids if chat_id not in known]
)
return [*items, *extra]
@router.get("/discover/{chat_id}")
async def discover_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
) -> DiscoverItem:
return await discover.get_item(pool, account_id, chat_id)
@router.post("/chats/{chat_id}/track", status_code=201)
async def track_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, body: TrackRequest
) -> DiscoverItem:
kind = await discover.chat_kind(pool, body.account_id, chat_id)
toggles = TRACKING[kind]
policy_id = await pool.fetchval(_CHAT_POLICY_ID, body.account_id, chat_id)
if policy_id is None:
await policy_repository.create_policy(
pool, body.account_id, ScopeType.CHAT, chat_id, toggles
)
else:
await policy_repository.update_policy(pool, policy_id, toggles)
await pool.execute(f"NOTIFY {POLICY_CHANGED_CHANNEL}")
await enqueue(pool, body.account_id, "enrich_chat", {"chat_id": chat_id})
if body.backfill:
await enqueue(
pool,
body.account_id,
"backfill",
{"chat_id": chat_id, "media": True, "full": True},
)
return await discover.get_item(pool, body.account_id, chat_id)
@router.post("/contacts/sync", status_code=201)
async def sync_contacts(
pool: FromDishka[asyncpg.Pool], body: SyncContactsRequest
) -> dict[str, int]:
job_id = await enqueue(pool, body.account_id, "sync_contacts", {})
return {"job_id": job_id}
+106
View File
@@ -0,0 +1,106 @@
from datetime import UTC, datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query, Request
from fastapi.responses import FileResponse, PlainTextResponse
from utils.files import (
content_disposition,
counts_as_download,
is_inline_mime,
resolve_mime,
)
from utils.read import shares
from utils.storage import ContentAddressedStorage
router = APIRouter(tags=["files"], route_class=DishkaRoute)
_GONE = "This link is no longer available.\n"
_MISSING = "Not found.\n"
_NO_STORE = {"Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow"}
def _client_ip(request: Request) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
def _expired(row: asyncpg.Record) -> bool:
expires_at = row["expires_at"]
return expires_at is not None and expires_at <= datetime.now(UTC)
def _exhausted(row: asyncpg.Record) -> bool:
limit = row["max_downloads"]
return limit is not None and row["download_count"] >= limit
async def _serve(
request: Request,
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
token: str,
*,
dl: bool,
) -> FileResponse | PlainTextResponse:
row = await shares.share_by_token(pool, token)
if row is None:
return PlainTextResponse(_MISSING, status_code=404, headers=_NO_STORE)
method = request.method
agent = request.headers.get("user-agent")
ip = _client_ip(request)
counts = counts_as_download(method, agent, request.headers.get("range"))
if row["revoked_at"] is not None or _expired(row) or _exhausted(row):
await shares.record_hit(pool, row["id"], method, ip, agent, counted=False)
return PlainTextResponse(_GONE, status_code=410, headers=_NO_STORE)
if not storage.exists(row["storage_key"]):
return PlainTextResponse(_MISSING, status_code=404, headers=_NO_STORE)
if counts and not await shares.consume_download(pool, row["id"]):
await shares.record_hit(pool, row["id"], method, ip, agent, counted=False)
return PlainTextResponse(_GONE, status_code=410, headers=_NO_STORE)
await shares.record_hit(pool, row["id"], method, ip, agent, counted=counts)
mime = resolve_mime(row["kind"], row["mime"], row["file_name"])
attachment = dl or not is_inline_mime(mime)
return FileResponse(
storage.url(row["storage_key"]),
media_type=mime,
headers={
**_NO_STORE,
"Content-Disposition": content_disposition(
row["file_name"], attachment=attachment
),
},
)
@router.api_route("/f/{token}", methods=["GET", "HEAD"], response_model=None)
async def serve_shared_file(
request: Request,
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
token: str,
dl: Annotated[bool, Query()] = False,
) -> FileResponse | PlainTextResponse:
return await _serve(request, pool, storage, token, dl=dl)
@router.api_route("/f/{token}/{name}", methods=["GET", "HEAD"], response_model=None)
async def serve_shared_file_named(
request: Request,
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
token: str,
name: str, # noqa: ARG001
dl: Annotated[bool, Query()] = False,
) -> FileResponse | PlainTextResponse:
return await _serve(request, pool, storage, token, dl=dl)
+25 -2
View File
@@ -5,6 +5,8 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS
from utils.files import content_disposition, media_file_name, resolve_mime
from utils.read.media import (
get_media,
get_media_version,
@@ -16,6 +18,15 @@ from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
Download = Annotated[bool, Query()]
def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]:
return {
**headers,
"Content-Disposition": content_disposition(file_name, attachment=True),
}
@router.get("/{media_id}/meta")
async def media_meta(pool: FromDishka[asyncpg.Pool], media_id: int) -> MediaView:
@@ -40,13 +51,20 @@ async def serve_media_version(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
version_id: int,
download: Download = False,
) -> FileResponse:
version = await get_media_version(pool, version_id)
if version is None:
raise HTTPException(status_code=404, detail="media version not found")
headers = IMMUTABLE_HEADERS
if download:
headers = _attachment(
headers, media_file_name(version.kind, version.mime, version_id)
)
return FileResponse(
storage.url(version.storage_key),
media_type=version.mime or "application/octet-stream",
media_type=resolve_mime(version.kind, version.mime),
headers=headers,
)
@@ -68,6 +86,7 @@ async def serve_media(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
media_id: int,
download: Download = False,
) -> FileResponse:
media = await get_media(pool, media_id)
if media is None:
@@ -77,7 +96,11 @@ async def serve_media(
status_code=409,
detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
)
headers = DAY_HEADERS
if download:
headers = _attachment(headers, media.file_name or f"media_{media_id}")
return FileResponse(
storage.url(media.storage_key),
media_type=media.mime or "application/octet-stream",
media_type=resolve_mime(media.kind, media.mime, media.file_name),
headers=headers,
)
+7 -1
View File
@@ -71,9 +71,15 @@ async def get_policy(pool: FromDishka[asyncpg.Pool], policy_id: int) -> PolicyRe
@router.put("/{policy_id}")
async def update_policy(
pool: FromDishka[asyncpg.Pool], policy_id: int, body: CaptureToggles
pool: FromDishka[asyncpg.Pool],
policy_id: int,
body: CaptureToggles,
account_id: Annotated[int | None, Query()] = None,
) -> PolicyRecord:
if account_id is None:
record = await repository.update_policy(pool, policy_id, body)
else:
record = await repository.override_policy(pool, policy_id, account_id, body)
if record is None:
raise HTTPException(status_code=404, detail="policy not found")
await pool.execute(f"NOTIFY {POLICY_CHANGED_CHANNEL}")
+233
View File
@@ -0,0 +1,233 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.files import (
expiry_from_seconds,
media_file_name,
resolve_mime,
story_file_name,
)
from utils.read import chats, peers, shares
from utils.read.media import get_media, get_media_version
from utils.read.models import DEFAULT_LIMIT, FileShareHitView, FileShareView, Page
router = APIRouter(prefix="/api/shares", tags=["shares"], route_class=DishkaRoute)
class ShareCreate(BaseModel):
account_id: int
kind: str
media_id: int | None = None
version_id: int | None = None
peer_id: int | None = None
story_id: int | None = None
expires_in_seconds: int | None = None
max_downloads: int | None = None
class ShareUpdate(BaseModel):
expires_in_seconds: int | None = None
max_downloads: int | None = None
keep_expiry: bool = False
class Subject(BaseModel):
storage_key: str
file_name: str
mime: str | None
file_size: int | None
title: str | None
chat_id: int | None = None
message_id: int | None = None
peer_id: int | None = None
story_id: int | None = None
_NOT_STORED = "file is not downloaded yet"
async def _chat_title(pool: asyncpg.Pool, account_id: int, chat_id: int) -> str | None:
chat = await chats.get_chat(pool, account_id, chat_id)
return chat.title if chat else None
async def _media_subject(pool: asyncpg.Pool, media_id: int) -> Subject:
media = await get_media(pool, media_id)
if media is None:
raise HTTPException(status_code=404, detail="media not found")
if not media.downloaded or media.storage_key is None:
raise HTTPException(status_code=409, detail=_NOT_STORED)
file_name = media.file_name or f"media_{media.id}"
return Subject(
storage_key=media.storage_key,
file_name=file_name,
mime=resolve_mime(media.kind, media.mime, file_name),
file_size=media.file_size,
title=await _chat_title(pool, media.account_id, media.chat_id),
chat_id=media.chat_id,
message_id=media.message_id,
)
async def _version_subject(pool: asyncpg.Pool, version_id: int) -> Subject:
version = await get_media_version(pool, version_id)
if version is None:
raise HTTPException(status_code=404, detail="media version not found")
version_name = media_file_name(version.kind, version.mime, version_id)
return Subject(
storage_key=version.storage_key,
file_name=version_name,
mime=resolve_mime(version.kind, version.mime, version_name),
file_size=version.file_size,
title=None,
chat_id=None,
message_id=None,
)
async def _story_subject(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> Subject:
story = await peers.get_story(pool, account_id, peer_id, story_id)
if story is None:
raise HTTPException(status_code=404, detail="story not found")
if not story.downloaded or story.storage_key is None:
raise HTTPException(status_code=409, detail=_NOT_STORED)
return Subject(
storage_key=story.storage_key,
file_name=story_file_name(peer_id, story_id, story.media_kind),
mime="video/mp4" if story.media_kind == "video" else "image/jpeg",
file_size=None,
title=await _chat_title(pool, account_id, peer_id),
peer_id=peer_id,
story_id=story_id,
)
async def _resolve(pool: asyncpg.Pool, body: ShareCreate) -> Subject:
if body.kind == "media" and body.media_id is not None:
return await _media_subject(pool, body.media_id)
if body.kind == "media_version" and body.version_id is not None:
return await _version_subject(pool, body.version_id)
if body.kind == "story" and body.peer_id is not None and body.story_id is not None:
return await _story_subject(pool, body.account_id, body.peer_id, body.story_id)
raise HTTPException(status_code=422, detail="unsupported share subject")
@router.get("")
async def list_shares(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
active_only: Annotated[bool, Query()] = False,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[FileShareView]:
return await shares.list_shares(
pool, account_id, Page(limit=limit, offset=offset), active_only=active_only
)
@router.post("", status_code=201)
async def create_share(
pool: FromDishka[asyncpg.Pool], body: ShareCreate
) -> FileShareView:
subject = await _resolve(pool, body)
existing = await shares.find_active_share(
pool, body.account_id, subject.storage_key
)
if existing is not None:
return existing
return await shares.create_share(
pool,
body.account_id,
body.kind,
subject.storage_key,
subject.file_name,
mime=subject.mime,
file_size=subject.file_size,
title=subject.title,
chat_id=subject.chat_id,
message_id=subject.message_id,
peer_id=subject.peer_id,
story_id=subject.story_id,
expires_at=expiry_from_seconds(body.expires_in_seconds),
max_downloads=body.max_downloads,
)
@router.get("/lookup")
async def lookup_share(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
kind: Annotated[str, Query()],
media_id: Annotated[int | None, Query()] = None,
version_id: Annotated[int | None, Query()] = None,
peer_id: Annotated[int | None, Query()] = None,
story_id: Annotated[int | None, Query()] = None,
) -> FileShareView | None:
body = ShareCreate(
account_id=account_id,
kind=kind,
media_id=media_id,
version_id=version_id,
peer_id=peer_id,
story_id=story_id,
)
subject = await _resolve(pool, body)
return await shares.find_active_share(pool, account_id, subject.storage_key)
@router.get("/{share_id}/hits")
async def share_hits(
pool: FromDishka[asyncpg.Pool],
share_id: int,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[FileShareHitView]:
return await shares.list_hits(pool, share_id, Page(limit=limit, offset=offset))
@router.patch("/{share_id}")
async def update_share(
pool: FromDishka[asyncpg.Pool], share_id: int, body: ShareUpdate
) -> FileShareView:
current = await shares.get_share(pool, share_id)
if current is None:
raise HTTPException(status_code=404, detail="share not found")
expires_at = (
current.expires_at
if body.keep_expiry
else expiry_from_seconds(body.expires_in_seconds)
)
updated = await shares.update_share(
pool, share_id, expires_at=expires_at, max_downloads=body.max_downloads
)
if updated is None:
raise HTTPException(status_code=404, detail="share not found")
return updated
@router.post("/{share_id}/revoke")
async def revoke_share(pool: FromDishka[asyncpg.Pool], share_id: int) -> FileShareView:
share = await shares.revoke_share(pool, share_id)
if share is None:
raise HTTPException(status_code=404, detail="share not found")
return share
@router.post("/{share_id}/reissue")
async def reissue_share(pool: FromDishka[asyncpg.Pool], share_id: int) -> FileShareView:
share = await shares.rotate_token(pool, share_id)
if share is None:
raise HTTPException(status_code=404, detail="share not found")
return share
@router.delete("/{share_id}", status_code=204)
async def delete_share(pool: FromDishka[asyncpg.Pool], share_id: int) -> None:
if not await shares.delete_share(pool, share_id):
raise HTTPException(status_code=404, detail="share not found")
+8
View File
@@ -5,6 +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.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage
@@ -36,13 +37,20 @@ async def serve_story_media(
peer_id: int,
story_id: int,
account_id: AccountId,
download: Annotated[bool, Query()] = False,
) -> FileResponse:
story = await peers.get_story(pool, account_id, peer_id, story_id)
if story is None:
raise HTTPException(status_code=404, detail="story not found")
if not story.downloaded or story.storage_key is None:
raise HTTPException(status_code=409, detail="story media not downloaded")
headers = {}
if download:
headers["Content-Disposition"] = content_disposition(
story_file_name(peer_id, story_id, story.media_kind), attachment=True
)
return FileResponse(
storage.url(story.storage_key),
media_type=_STORY_MIME.get(story.media_kind or "", "application/octet-stream"),
headers=headers,
)
+2 -2
View File
@@ -1,3 +1,3 @@
from userbot.modules.client import PyroClient
from userbot.modules.client import DEVICE_MODEL, DEVICE_MODEL_LIMIT, PyroClient
__all__ = ["PyroClient"]
__all__ = ["DEVICE_MODEL", "DEVICE_MODEL_LIMIT", "PyroClient"]
+7 -1
View File
@@ -11,7 +11,13 @@ from utils.events import notify_bg_event
@PyroClient.on_edited_message()
async def on_edited_message(client: PyroClient, message: Message) -> None:
ctx = client.capture
if ctx is None or message.empty or message.chat is None or message.date is None:
if (
ctx is None
or message.empty
or message.scheduled
or message.chat is None
or message.date is None
):
return
chat = message.chat
chat_id = chat.id or 0
+7 -1
View File
@@ -11,7 +11,13 @@ from utils.events import notify_bg_event
@PyroClient.on_message()
async def on_message(client: PyroClient, message: Message) -> None:
ctx = client.capture
if ctx is None or message.empty or message.chat is None or message.date is None:
if (
ctx is None
or message.empty
or message.scheduled
or message.chat is None
or message.date is None
):
return
meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_text(meta.chat_id, message.id, message.text or message.caption)
+3 -41
View File
@@ -1,52 +1,14 @@
from io import BytesIO
from pyrogram.types import Story
from userbot import PyroClient
from userbot.modules.stories import repository
def _peer_id(story: Story) -> int:
if story.chat is not None:
return story.chat.id or 0
if story.from_user is not None:
return story.from_user.id or 0
return 0
from userbot.modules.stories.service import save_story
@PyroClient.on_story()
async def on_story(client: PyroClient, story: Story) -> None:
ctx = client.capture
if ctx is None:
if client.capture is None:
return
media_kind = story.media.name.lower() if story.media else None
storage_key: str | None = None
file_size: int | None = None
downloaded = False
if not story.deleted and story.media is not None:
buffer = await client.download_media(story, in_memory=True)
if isinstance(buffer, BytesIO):
data = buffer.getvalue()
storage_key = ctx.storage.put(data)
file_size = len(data)
downloaded = True
await repository.upsert_story(
ctx.pool,
ctx.account_id,
_peer_id(story),
story.id,
story.date,
story.expire_date,
story.caption,
media_kind,
storage_key,
file_size,
story.views,
str(story.raw),
pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded,
)
await save_story(client, client.capture, story)
handlers = on_story.handlers
@@ -118,6 +118,16 @@ async def upsert_message( # noqa: PLR0913
)
async def max_message_id(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> int | None:
return await pool.fetchval(
"SELECT max(message_id) FROM messages WHERE account_id = $1 AND chat_id = $2",
account_id,
chat_id,
)
async def mark_deleted_box(
pool: asyncpg.Pool, account_id: int, message_ids: list[int]
) -> None:
+15 -4
View File
@@ -6,19 +6,30 @@ if TYPE_CHECKING:
from userbot.modules.capture import CaptureContext
DEVICE_MODEL = "Beavergram"
DEVICE_MODEL_LIMIT = 32
class PyroClient(Client):
def __init__(
self, name: str, *, workdir: str = "sessions", load_handlers: bool = True
self,
name: str,
*,
workdir: str = "sessions",
device_model: str | None = None,
load_handlers: bool = True,
) -> None:
super().__init__(
name,
workdir=workdir,
api_id=2040,
api_hash="b18441a1ff607e10a989891a5462e627",
device_model="Desktop",
device_model=device_model or DEVICE_MODEL,
system_version="Windows 11 x64",
app_version="6.2.4 x64",
app_version="7.0.8 x64",
lang_pack="tdesktop",
lang_code="en",
system_lang_code="en-US",
client_platform=enums.ClientPlatform.DESKTOP,
)
self.capture: CaptureContext | None = None
@@ -30,4 +41,4 @@ class PyroClient(Client):
self.add_handler(*handler)
__all__ = ["PyroClient"]
__all__ = ["DEVICE_MODEL", "DEVICE_MODEL_LIMIT", "PyroClient"]
@@ -1,19 +1,25 @@
from userbot.modules.jobs.handlers import (
backfill,
backfill_stories,
enrich_chat,
fetch_avatar,
fetch_custom_emoji,
fetch_media,
search_peers,
sync_contacts,
sync_dialogs,
transcribe,
)
__all__ = [
"backfill",
"backfill_stories",
"enrich_chat",
"fetch_avatar",
"fetch_custom_emoji",
"fetch_media",
"search_peers",
"sync_contacts",
"sync_dialogs",
"transcribe",
]
@@ -1,7 +1,11 @@
from pyrogram import Client
from pyrogram.errors import PeerIdInvalid
from pyrogram.types import Message
from userbot.modules.capture import capture_message
from userbot.modules.capture import repository as capture_repo
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.stt import repository as stt_repo
@@ -12,6 +16,33 @@ from utils.policy.models import CaptureToggles
SAVE_EVERY = 100
async def resolve_min_id(ctx: JobContext, chat_id: int) -> int:
cursor = ctx.job.cursor or {}
if "min_id" in cursor:
return int(cursor["min_id"])
if ctx.job.params.get("full"):
return 0
newest = await capture_repo.max_message_id(ctx.pool, ctx.account_id, chat_id)
return newest + 1 if newest else 0
async def maybe_transcribe(
client: Client,
capture: CaptureContext,
chat_id: int,
message: Message,
self_id: int | None,
) -> None:
if not (should_transcribe_on_backfill(message, self_id) and message.chat):
return
meta = meta_from_chat(message.chat, capture.contacts.ids)
already = await stt_repo.is_transcribed(
capture.pool, capture.account_id, chat_id, message.id
)
if capture.resolve(meta).stt and not already:
await safe_transcribe(client, capture, chat_id, message.id)
@register("backfill")
async def backfill(ctx: JobContext) -> None:
client = ctx.client
@@ -26,24 +57,21 @@ async def backfill(ctx: JobContext) -> None:
media=bool(ctx.job.params.get("media")),
self_destruct_media=False,
)
max_id = (ctx.job.cursor or {}).get("max_id", 0)
max_id = int((ctx.job.cursor or {}).get("max_id", 0))
min_id = await resolve_min_id(ctx, chat_id)
await ctx.save_cursor({"max_id": max_id, "min_id": min_id})
processed = ctx.job.progress.get("processed", 0)
kwargs = {"max_id": max_id} if max_id else {}
self_id = client.me.id if client.me else None
try:
async for message in client.get_chat_history(chat_id, **kwargs):
async for message in client.get_chat_history(
chat_id, max_id=max_id, min_id=min_id
):
await capture_message(client, message, capture, toggles)
if should_transcribe_on_backfill(message, self_id) and message.chat:
meta = meta_from_chat(message.chat, capture.contacts.ids)
already = await stt_repo.is_transcribed(
capture.pool, capture.account_id, chat_id, message.id
)
if capture.resolve(meta).stt and not already:
await safe_transcribe(client, capture, chat_id, message.id)
await maybe_transcribe(client, capture, chat_id, message, self_id)
processed += 1
if processed % SAVE_EVERY == 0:
next_max = message.id - 1
await ctx.save_cursor({"max_id": next_max})
await ctx.save_cursor({"max_id": next_max, "min_id": min_id})
await ctx.report_progress({"processed": processed, "max_id": next_max})
if await ctx.is_canceled():
return
@@ -0,0 +1,70 @@
from collections.abc import AsyncIterator, Callable
from pyrogram import Client
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Story
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.stories.service import save_story
SAVE_EVERY = 10
StorySource = Callable[[], AsyncIterator[Story]]
def _sources(client: Client, peer_id: int, *, own: bool) -> dict[str, StorySource]:
sources: dict[str, StorySource] = {
"active": lambda: client.get_chat_stories(peer_id),
"pinned": lambda: client.get_pinned_stories(peer_id),
}
if own:
sources["archived"] = lambda: client.get_archived_stories(peer_id)
return sources
async def _drain(
ctx: JobContext, capture: CaptureContext, name: str, source: StorySource
) -> int:
client = ctx.client
if client is None:
return 0
saved = 0
async for story in source():
try:
await save_story(client, capture, story)
except (FloodWait, FloodPremiumWait):
raise
except RPCError:
continue
saved += 1
if saved % SAVE_EVERY == 0:
await ctx.report_progress({"saved": saved, "source": name})
if await ctx.is_canceled():
break
return saved
@register("backfill_stories")
async def backfill_stories(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
peer_id = ctx.job.params["peer_id"]
own = client.me is not None and client.me.id == peer_id
saved = 0
errors: dict[str, str] = {}
for name, source in _sources(client, peer_id, own=own).items():
try:
saved += await _drain(ctx, capture, name, source)
except (FloodWait, FloodPremiumWait):
raise
except RPCError as exc:
errors[name] = type(exc).__name__
if await ctx.is_canceled():
break
await ctx.report_progress({"saved": saved, "done": True, "errors": errors})
@@ -0,0 +1,102 @@
import re
from pyrogram import Client, raw
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import Chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.snapshots import save_chat
DEFAULT_LIMIT = 30
_USERNAME = re.compile(r"^[a-z][a-z0-9_]{3,31}$", re.IGNORECASE)
_PREFIXES = ("https://t.me/", "http://t.me/", "t.me/", "@")
def _normalize(query: str) -> str:
text = query.strip()
for prefix in _PREFIXES:
if text.lower().startswith(prefix):
text = text[len(prefix) :]
break
return text.strip("/")
_SOURCE_TYPES = (raw.types.User, raw.types.Chat, raw.types.Channel)
def _source(
peer: raw.base.Peer, users: dict, chats: dict
) -> raw.types.User | raw.types.Chat | raw.types.Channel | None:
if isinstance(peer, raw.types.PeerUser):
source = users.get(peer.user_id)
elif isinstance(peer, raw.types.PeerChannel):
source = chats.get(peer.channel_id)
elif isinstance(peer, raw.types.PeerChat):
source = chats.get(peer.chat_id)
else:
return None
return source if isinstance(source, _SOURCE_TYPES) else None
async def _save_found(
client: Client, ctx: CaptureContext, peer: raw.base.Peer, users: dict, chats: dict
) -> int | None:
source = _source(peer, users, chats)
if source is None:
return None
chat = Chat._parse_chat(client, source) # noqa: SLF001
if chat is None or chat.id is None:
return None
await save_chat(ctx, chat)
return chat.id
async def _resolve(client: Client, ctx: CaptureContext, query: str) -> int | None:
try:
chat = await client.get_chat(query)
except (BadRequest, Forbidden):
return None
if not isinstance(chat, Chat) or chat.id is None:
return None
await save_chat(ctx, chat)
return chat.id
async def _search(
client: Client, query: str, limit: int
) -> raw.base.contacts.Found | None:
try:
return await client.invoke(raw.functions.contacts.Search(q=query, limit=limit))
except (BadRequest, Forbidden):
return None
@register("search_peers")
async def search_peers(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
query = _normalize(ctx.job.params.get("query", ""))
if not query:
await ctx.report_progress({"ids": [], "done": True})
return
limit = int(ctx.job.params.get("limit", DEFAULT_LIMIT))
found = await _search(client, query, limit)
ids: list[int] = []
if found is not None:
users = {user.id: user for user in found.users}
chats = {chat.id: chat for chat in found.chats}
for peer in (*found.my_results, *found.results):
peer_id = await _save_found(client, capture, peer, users, chats)
if peer_id is not None and peer_id not in ids:
ids.append(peer_id)
if _USERNAME.match(query):
resolved = await _resolve(client, capture, query)
if resolved is not None and resolved not in ids:
ids.insert(0, resolved)
await ctx.report_progress({"ids": ids, "done": True})
@@ -0,0 +1,36 @@
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
@register("sync_contacts")
async def sync_contacts(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
contacts = await client.get_contacts()
processed = 0
for user in contacts:
if not isinstance(user, User):
continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool,
ctx.account_id,
user.id,
"peer",
photo_unique_id,
photo_file_id,
)
processed += 1
await capture.contacts.refresh()
await ctx.report_progress({"processed": processed, "done": True})
@@ -1,16 +1,14 @@
from datetime import UTC, datetime
from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import Chat, User
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_chat, snapshot_from_high_level
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
from userbot.modules.profiles.snapshots import save_group, save_private
SAVE_EVERY = 100
USERS_BATCH = 200
@@ -21,16 +19,6 @@ ON CONFLICT (account_id, chat_id) DO UPDATE SET updated_at = now()
"""
async def _save_private(ctx: CaptureContext, chat: Chat, chat_id: int) -> bool:
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, str(chat))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
)
return bool(fields.first_name or fields.last_name or fields.username)
async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) -> None:
for start in range(0, len(ids), USERS_BATCH):
batch = ids[start : start + USERS_BATCH]
@@ -55,28 +43,6 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
)
async def _save_group(ctx: CaptureContext, chat: Chat, chat_id: int) -> None:
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
str(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
@register("sync_dialogs")
async def sync_dialogs(ctx: JobContext) -> None:
client = ctx.client
@@ -94,10 +60,10 @@ async def sync_dialogs(ctx: JobContext) -> None:
chat_id = chat.id
try:
if chat_id > 0:
if not await _save_private(capture, chat, chat_id):
if not await save_private(capture, chat):
nameless.append(chat_id)
else:
await _save_group(capture, chat, chat_id)
await save_group(capture, chat)
except (BadRequest, Forbidden):
pass
await ctx.pool.execute(_UPSERT_DIALOG, ctx.account_id, chat_id)
@@ -0,0 +1,50 @@
from datetime import UTC, datetime
from pyrogram.types import Chat
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.profiles.parse import snapshot_from_chat
from userbot.modules.profiles.repository import write_profile
async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
chat_id = chat.id or 0
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, str(chat))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
)
return bool(fields.first_name or fields.last_name or fields.username)
async def save_group(ctx: CaptureContext, chat: Chat) -> None:
chat_id = chat.id or 0
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
str(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
async def save_chat(ctx: CaptureContext, chat: Chat) -> None:
if (chat.id or 0) > 0:
await save_private(ctx, chat)
else:
await save_group(ctx, chat)
@@ -22,6 +22,20 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
"""
async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> bool:
return bool(
await pool.fetchval(
"SELECT downloaded FROM stories "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
)
)
async def upsert_story( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
@@ -0,0 +1,49 @@
from io import BytesIO
from pyrogram import Client
from pyrogram.types import Story
from userbot.modules.capture.context import CaptureContext
from userbot.modules.stories import repository
def story_peer_id(story: Story) -> int:
if story.chat is not None:
return story.chat.id or 0
if story.from_user is not None:
return story.from_user.id or 0
return 0
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story)
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):
buffer = await client.download_media(story, in_memory=True)
if isinstance(buffer, BytesIO):
data = buffer.getvalue()
storage_key = capture.storage.put(data)
file_size = len(data)
downloaded = True
await repository.upsert_story(
capture.pool,
capture.account_id,
peer_id,
story.id,
story.date,
story.expire_date,
story.caption,
story.media.name.lower() if story.media else None,
storage_key,
file_size,
story.views,
str(story.raw),
pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded,
)
+138 -97
View File
@@ -1,7 +1,7 @@
import asyncio
import contextlib
import json
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -15,87 +15,154 @@ from userbot.modules.jobs import JobConsumer
from utils.env import env
from utils.jobs import enqueue
from utils.logging import logger, setup_logging
from utils.read.accounts import (
ACCOUNTS_CHANGED_CHANNEL,
inactive_session_names,
session_device_models,
sync_account,
)
from utils.read.watches import WATCHES_CHANGED_CHANNEL
from utils.storage import ContentAddressedStorage
setup_logging()
_UPSERT_ACCOUNT = """
INSERT INTO accounts
(tg_user_id, label, phone, session_name, is_active, raw, updated_at)
VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, now())
ON CONFLICT (tg_user_id) DO UPDATE SET
label = EXCLUDED.label,
phone = EXCLUDED.phone,
session_name = EXCLUDED.session_name,
is_active = TRUE,
raw = EXCLUDED.raw,
updated_at = now()
RETURNING account_id
"""
@dataclass
class RunningAccount:
client: PyroClient
consumer_task: asyncio.Task
device_model: str | None
def _discover_sessions(sessions_dir: Path) -> list[Path]:
def _sessions_dir() -> Path:
sessions_dir = Path(env.tg.sessions_dir)
sessions_dir.mkdir(parents=True, exist_ok=True)
return sorted(sessions_dir.glob("*.session"))
return sessions_dir
async def _sync_account(
pool: asyncpg.Pool, client: PyroClient, session_name: str
) -> int | None:
me = client.me
if not me:
return None
raw = json.dumps(
{
"id": me.id,
"first_name": me.first_name,
"last_name": me.last_name,
"username": me.username,
"phone_number": me.phone_number,
}
)
label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username
account_id = await pool.fetchval(
_UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw
)
logger.info(f"[green]Account synced:[/] {label} ({me.id})")
return account_id
async def _cancel(task: asyncio.Task) -> None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def _setup_capture(
pool: asyncpg.Pool,
client: PyroClient,
account_id: int,
storage: ContentAddressedStorage,
) -> None:
client.capture = await build_capture_context(client, pool, storage, account_id)
logger.info("[green]Capture context ready.[/]")
async def _enqueue_sync_dialogs(pool: asyncpg.Pool, account_id: int) -> None:
async def _enqueue_once(pool: asyncpg.Pool, account_id: int, kind: str) -> None:
existing = await pool.fetchval(
"SELECT 1 FROM jobs WHERE account_id = $1 AND kind = 'sync_dialogs' "
"SELECT 1 FROM jobs WHERE account_id = $1 AND kind = $2 "
"AND status IN ('pending', 'running') LIMIT 1",
account_id,
kind,
)
if existing is None:
await enqueue(pool, account_id, "sync_dialogs", {})
logger.info("[green]Queued sync_dialogs.[/]")
await enqueue(pool, account_id, kind, {})
logger.info(f"[green]Queued {kind}.[/]")
class AccountRegistry:
def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
self._pool = pool
self._storage = storage
self._running: dict[str, RunningAccount] = {}
self._lock = asyncio.Lock()
@property
def clients(self) -> list[PyroClient]:
return [account.client for account in self._running.values()]
async def sync(self) -> None:
async with self._lock:
inactive = await inactive_session_names(self._pool)
models = await session_device_models(self._pool)
present: set[str] = set()
for path in sorted(_sessions_dir().glob("*.session")):
if path.stem in inactive:
await self._log_out(path)
continue
present.add(path.stem)
device_model = models.get(path.stem)
running = self._running.get(path.stem)
if running is not None and running.device_model != device_model:
await self._stop(path.stem)
running = None
if running is None:
await self._start(path, device_model)
for session_name in set(self._running) - present:
await self._stop(session_name)
async def close(self) -> None:
for session_name in list(self._running):
await self._stop(session_name)
async def _start(self, path: Path, device_model: str | None) -> None:
session_name = path.stem
client = PyroClient(
session_name, workdir=str(path.parent), device_model=device_model
)
try:
await client.start()
me = client.me
if me is None:
msg = f"session {session_name} is not authorized"
raise RuntimeError(msg)
account_id = await sync_account(self._pool, me, session_name)
client.capture = await build_capture_context(
client, self._pool, self._storage, account_id
)
except Exception:
logger.exception(f"[red]Failed to start session:[/] {session_name}")
with contextlib.suppress(Exception):
await client.stop()
return
consumer = JobConsumer(client, self._pool, account_id)
self._running[session_name] = RunningAccount(
client, asyncio.create_task(consumer.run()), device_model
)
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
await _enqueue_once(self._pool, account_id, "sync_dialogs")
await _enqueue_once(self._pool, account_id, "sync_contacts")
async def _stop(self, session_name: str) -> None:
account = self._running.pop(session_name, None)
if account is None:
return
await _cancel(account.consumer_task)
with contextlib.suppress(Exception):
await account.client.stop()
logger.info(f"[yellow]Client stopped:[/] {session_name}")
async def _log_out(self, path: Path) -> None:
account = self._running.pop(path.stem, None)
if account is not None:
await _cancel(account.consumer_task)
client = account.client
else:
client = PyroClient(
path.stem, workdir=str(path.parent), load_handlers=False
)
with contextlib.suppress(Exception):
if account is None:
await client.start()
await client.log_out()
with contextlib.suppress(Exception):
await client.stop()
path.unlink(missing_ok=True) # noqa: ASYNC240
logger.info(f"[yellow]Account logged out:[/] {path.stem}")
async def _listen_changes(
clients: list[PyroClient], tasks: set[asyncio.Task]
registry: AccountRegistry, tasks: set[asyncio.Task]
) -> asyncpg.Connection:
def spawn(coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro)
tasks.add(task)
task.add_done_callback(tasks.discard)
def reload(
make_coro: Callable[[CaptureContext], Coroutine[Any, Any, None]],
) -> None:
for client in clients:
if client.capture is None:
continue
task = asyncio.create_task(make_coro(client.capture))
tasks.add(task)
task.add_done_callback(tasks.discard)
for client in registry.clients:
if client.capture is not None:
spawn(make_coro(client.capture))
def on_policy(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
@@ -107,9 +174,15 @@ async def _listen_changes(
) -> None:
reload(lambda capture: capture.watches.refresh())
def on_accounts(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
spawn(registry.sync())
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener("policy_changed", on_policy)
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
return conn
@@ -117,53 +190,21 @@ async def runner() -> None:
pool = await container.get(asyncpg.Pool)
storage = await container.get(ContentAddressedStorage)
sessions_dir = Path(env.tg.sessions_dir)
session_files = _discover_sessions(sessions_dir)
if not session_files:
logger.warning(
f"[yellow]No .session files in {sessions_dir}/. "
f"Log in first, then restart userbot.[/]"
)
clients: list[PyroClient] = []
reload_tasks: set[asyncio.Task] = set()
consumer_tasks: list[asyncio.Task] = []
registry = AccountRegistry(pool, storage)
tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None
try:
for session_path in session_files:
session_name = session_path.stem
client = PyroClient(session_name, workdir=str(sessions_dir))
await client.start()
clients.append(client)
logger.info(
f"[green]Client started:[/] "
f"{client.me.full_name if client.me else 'unknown'} "
f"{client.me.id if client.me else 'unknown'}"
)
account_id = await _sync_account(pool, client, session_name)
if account_id is not None:
await _setup_capture(pool, client, account_id, storage)
consumer = JobConsumer(client, pool, account_id)
consumer_tasks.append(asyncio.create_task(consumer.run()))
await _enqueue_sync_dialogs(pool, account_id)
if clients:
listen_conn = await _listen_changes(clients, reload_tasks)
await registry.sync()
if not registry.clients:
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
listen_conn = await _listen_changes(registry, tasks)
logger.info("[green]Userbot running.[/]")
await asyncio.Event().wait()
finally:
for task in consumer_tasks:
task.cancel()
for task in consumer_tasks:
with contextlib.suppress(asyncio.CancelledError):
await task
if listen_conn is not None:
with contextlib.suppress(Exception):
await listen_conn.close()
for client in clients:
with contextlib.suppress(Exception):
await client.stop()
await registry.close()
await container.close()
+4
View File
@@ -0,0 +1,4 @@
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
DAY_HEADERS = {"Cache-Control": "public, max-age=86400"}
SHORT_HEADERS = {"Cache-Control": "public, max-age=300"}
NO_STORE_HEADERS = {"Cache-Control": "no-cache"}
+50
View File
@@ -493,3 +493,53 @@ class Dialog(SQLModel, table=True):
onupdate=func.now(),
)
)
class FileShare(SQLModel, table=True):
__tablename__ = "file_shares"
id: int | None = Field(default=None, primary_key=True)
account_id: int
token: str = Field(unique=True)
kind: str
storage_key: str
file_name: str
mime: str | None = None
file_size: int | None = Field(default=None, sa_column=Column(BigInteger))
title: str | None = None
chat_id: int | None = Field(default=None, sa_column=Column(BigInteger))
message_id: int | None = Field(default=None, sa_column=Column(BigInteger))
peer_id: int | None = Field(default=None, sa_column=Column(BigInteger))
story_id: int | None = Field(default=None, sa_column=Column(BigInteger))
expires_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True))
)
max_downloads: int | None = None
download_count: int = 0
last_download_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True))
)
revoked_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True))
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
class FileShareHit(SQLModel, table=True):
__tablename__ = "file_share_hits"
id: int | None = Field(default=None, sa_column=Column(BigInteger, primary_key=True))
share_id: int = Field(foreign_key="file_shares.id")
ts: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
method: str
ip: str | None = None
user_agent: str | None = None
counted: bool = False
+231
View File
@@ -0,0 +1,231 @@
import re
from datetime import UTC, datetime, timedelta
from urllib.parse import quote
_MIME_EXTENSIONS = {
"application/pdf": ".pdf",
"application/x-tgsticker": ".tgs",
"application/zip": ".zip",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"text/plain": ".txt",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"video/webm": ".webm",
}
_KIND_EXTENSIONS = {
"animation": ".mp4",
"audio": ".mp3",
"gif": ".mp4",
"photo": ".jpg",
"sticker": ".webp",
"video": ".mp4",
"video_note": ".mp4",
"voice": ".ogg",
}
_EXTENSION_MIMES = {
".flac": "audio/flac",
".gif": "image/gif",
".heic": "image/heic",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".m4a": "audio/mp4",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
}
_KIND_MIMES = {
"animation": "video/mp4",
"gif": "video/mp4",
"photo": "image/jpeg",
"video": "video/mp4",
"video_note": "video/mp4",
"voice": "audio/ogg",
}
_GENERIC_MIMES = {
"application/octet-stream",
"application/binary",
"binary/octet-stream",
"",
}
_INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
_INLINE_MIMES = {"application/pdf", "application/json"}
_PREVIEW_AGENTS = (
"telegrambot",
"twitterbot",
"facebookexternalhit",
"whatsapp",
"discordbot",
"slackbot",
"skypeuripreview",
"vkshare",
"redditbot",
"linkedinbot",
"embedly",
"quora link preview",
"googlebot",
"bingbot",
"applebot",
"yandexbot",
"duckduckbot",
"petalbot",
"ahrefsbot",
"semrushbot",
"headlesschrome",
"python-requests",
)
_TRANSLIT = {
"а": "a",
"б": "b",
"в": "v",
"г": "g",
"д": "d",
"е": "e",
"ё": "e",
"ж": "zh",
"з": "z",
"и": "i",
"й": "y",
"к": "k",
"л": "l",
"м": "m",
"н": "n",
"о": "o",
"п": "p",
"р": "r",
"с": "s",
"т": "t",
"у": "u",
"ф": "f",
"х": "h",
"ц": "c",
"ч": "ch",
"ш": "sh",
"щ": "sch",
"ъ": "",
"ы": "y",
"ь": "",
"э": "e",
"ю": "yu",
"я": "ya",
}
_URL_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
_URL_REPEATS = re.compile(r"_{2,}")
_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')
_SPACES = re.compile(r"\s+")
_NAME_LIMIT = 120
def extension_for(kind: str | None, mime: str | None) -> str:
if mime and mime in _MIME_EXTENSIONS:
return _MIME_EXTENSIONS[mime]
if kind and kind in _KIND_EXTENSIONS:
return _KIND_EXTENSIONS[kind]
if mime and "/" in mime:
tail = mime.rsplit("/", 1)[1].split(";")[0].strip()
if tail.isalnum():
return f".{tail}"
return ".bin"
def sanitize_name(name: str) -> str:
cleaned = _SPACES.sub(" ", _UNSAFE_CHARS.sub("_", name)).strip(" .")
if len(cleaned) > _NAME_LIMIT:
head, dot, tail = cleaned.rpartition(".")
cleaned = (
f"{head[: _NAME_LIMIT - len(tail) - 1]}{dot}{tail}"
if dot and len(tail) < 12 # noqa: PLR2004
else cleaned[:_NAME_LIMIT]
)
return cleaned or "file"
def url_slug(file_name: str) -> str:
lowered = "".join(
_TRANSLIT.get(ch, _TRANSLIT.get(ch.lower(), ch)) for ch in file_name
)
slug = _URL_REPEATS.sub("_", _URL_UNSAFE.sub("_", lowered)).strip("_.")
if not slug:
return "file"
return slug[:_NAME_LIMIT]
def media_file_name(
kind: str | None, mime: str | None, message_id: int, original: str | None = None
) -> str:
if original:
name = sanitize_name(original)
return name if "." in name else f"{name}{extension_for(kind, mime)}"
return f"{kind or 'media'}_{message_id}{extension_for(kind, mime)}"
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 resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None
) -> str:
if mime and mime.lower() not in _GENERIC_MIMES:
return mime
if file_name and "." in file_name:
by_extension = _EXTENSION_MIMES.get(f".{file_name.rsplit('.', 1)[1].lower()}")
if by_extension:
return by_extension
return _KIND_MIMES.get(kind or "", mime or "application/octet-stream")
def is_inline_mime(mime: str | None) -> bool:
if not mime:
return False
return mime in _INLINE_MIMES or mime.startswith(_INLINE_MIME_PREFIXES)
def content_disposition(file_name: str, *, attachment: bool) -> str:
kind = "attachment" if attachment else "inline"
ascii_name = file_name.encode("ascii", "replace").decode("ascii").replace('"', "_")
return f"{kind}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(file_name)}"
def is_preview_agent(user_agent: str | None) -> bool:
if not user_agent:
return False
lowered = user_agent.lower()
return any(marker in lowered for marker in _PREVIEW_AGENTS)
def counts_as_download(
method: str, user_agent: str | None, range_header: str | None
) -> bool:
if method.upper() != "GET" or is_preview_agent(user_agent):
return False
if range_header is None:
return True
return range_header.replace(" ", "").startswith("bytes=0-")
def expiry_from_seconds(seconds: int | None) -> datetime | None:
if seconds is None or seconds <= 0:
return None
return datetime.now(UTC) + timedelta(seconds=seconds)
+19
View File
@@ -16,3 +16,22 @@ DEFAULTS: dict[ChatKind, CaptureToggles] = {
backfill=True,
),
}
TRACKING: dict[ChatKind, CaptureToggles] = {
ChatKind.CHANNEL: CaptureToggles(
messages=True,
media=True,
reactions=True,
track_edits_deletes=True,
backfill=True,
),
ChatKind.GROUP: CaptureToggles(
messages=True,
media=True,
reactions=True,
track_edits_deletes=True,
profile_history=True,
backfill=True,
),
ChatKind.DM: DEFAULTS[ChatKind.DM],
}
+46 -3
View File
@@ -85,6 +85,18 @@ async def list_folders(pool: asyncpg.Pool, account_id: int) -> list[FolderSpec]:
return [_row_to_folder(row) for row in rows]
async def get_folder(
pool: asyncpg.Pool, account_id: int, folder_id: int
) -> FolderSpec | None:
row = await pool.fetchrow(
"SELECT folder_id, title, order_index, is_chatlist, raw "
"FROM folders WHERE account_id = $1 AND folder_id = $2",
account_id,
folder_id,
)
return _row_to_folder(row) if row else None
async def create_policy(
pool: asyncpg.Pool,
account_id: int | None,
@@ -110,10 +122,24 @@ async def get_policy(pool: asyncpg.Pool, policy_id: int) -> PolicyRecord | None:
return PolicyRecord(**dict(row)) if row else None
async def find_policy(
pool: asyncpg.Pool, account_id: int, scope_type: ScopeType, scope_id: int | None
) -> PolicyRecord | None:
row = await pool.fetchrow(
"SELECT * FROM capture_policy WHERE account_id = $1 AND scope_type = $2 "
"AND scope_id IS NOT DISTINCT FROM $3",
account_id,
scope_type.value,
scope_id,
)
return PolicyRecord(**dict(row)) if row else None
async def list_policies(pool: asyncpg.Pool, account_id: int) -> list[PolicyRecord]:
rows = await pool.fetch(
"SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL "
"ORDER BY scope_type, scope_id",
"SELECT DISTINCT ON (scope_type, scope_id) * FROM capture_policy "
"WHERE account_id = $1 OR account_id IS NULL "
"ORDER BY scope_type, scope_id, account_id NULLS LAST",
account_id,
)
return [PolicyRecord(**dict(row)) for row in rows]
@@ -131,6 +157,22 @@ async def update_policy(
return PolicyRecord(**dict(row)) if row else None
async def override_policy(
pool: asyncpg.Pool, policy_id: int, account_id: int, toggles: CaptureToggles
) -> PolicyRecord | None:
record = await get_policy(pool, policy_id)
if record is None:
return None
if record.account_id == account_id:
return await update_policy(pool, policy_id, toggles)
existing = await find_policy(pool, account_id, record.scope_type, record.scope_id)
if existing is not None:
return await update_policy(pool, existing.id, toggles)
return await create_policy(
pool, account_id, record.scope_type, record.scope_id, toggles
)
async def delete_policy(pool: asyncpg.Pool, policy_id: int) -> bool:
result = await pool.execute("DELETE FROM capture_policy WHERE id = $1", policy_id)
return result.endswith("1")
@@ -138,7 +180,8 @@ async def delete_policy(pool: asyncpg.Pool, policy_id: int) -> bool:
async def load_policy_set(pool: asyncpg.Pool, account_id: int) -> PolicySet:
rows = await pool.fetch(
"SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL",
"SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL "
"ORDER BY account_id NULLS FIRST",
account_id,
)
policies = PolicySet()
+83 -2
View File
@@ -1,7 +1,34 @@
import json
import asyncpg
from pyrogram.types import User
from utils.read.models import AccountView
ACCOUNTS_CHANGED_CHANNEL = "accounts_changed"
_ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active, device_model"
_UPSERT_ACCOUNT = """
INSERT INTO accounts
(tg_user_id, label, phone, session_name, is_active, raw, updated_at)
VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, now())
ON CONFLICT (tg_user_id) DO UPDATE SET
label = EXCLUDED.label,
phone = EXCLUDED.phone,
session_name = EXCLUDED.session_name,
is_active = TRUE,
raw = EXCLUDED.raw,
updated_at = now()
RETURNING account_id
"""
_SET_DEVICE_MODEL = f"""
UPDATE accounts SET device_model = $2, updated_at = now()
WHERE account_id = $1
RETURNING {_ACCOUNT_COLS}
""" # noqa: S608
async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
return await pool.fetchval(
@@ -11,7 +38,61 @@ async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]:
rows = await pool.fetch(
"SELECT account_id, label, phone, tg_user_id, is_active FROM accounts "
"ORDER BY account_id"
f"SELECT {_ACCOUNT_COLS} FROM accounts ORDER BY account_id" # noqa: S608
)
return [AccountView(**dict(row)) for row in rows]
async def get_account(pool: asyncpg.Pool, account_id: int) -> AccountView | None:
row = await pool.fetchrow(
f"SELECT {_ACCOUNT_COLS} FROM accounts WHERE account_id = $1", # noqa: S608
account_id,
)
return AccountView(**dict(row)) if row else None
async def sync_account(pool: asyncpg.Pool, me: User, session_name: str) -> int:
raw = json.dumps(
{
"id": me.id,
"first_name": me.first_name,
"last_name": me.last_name,
"username": me.username,
"phone_number": me.phone_number,
}
)
label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username
return await pool.fetchval(
_UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw
)
async def set_device_model(
pool: asyncpg.Pool, account_id: int, device_model: str
) -> AccountView | None:
row = await pool.fetchrow(_SET_DEVICE_MODEL, account_id, device_model)
return AccountView(**dict(row)) if row else None
async def session_device_models(pool: asyncpg.Pool) -> dict[str, str]:
rows = await pool.fetch(
"SELECT session_name, device_model FROM accounts WHERE device_model IS NOT NULL"
)
return {row["session_name"]: row["device_model"] for row in rows}
async def deactivate_account(pool: asyncpg.Pool, account_id: int) -> str | None:
return await pool.fetchval(
"UPDATE accounts SET is_active = FALSE, updated_at = now() "
"WHERE account_id = $1 RETURNING session_name",
account_id,
)
async def inactive_session_names(pool: asyncpg.Pool) -> set[str]:
rows = await pool.fetch("SELECT session_name FROM accounts WHERE NOT is_active")
return {row["session_name"] for row in rows}
async def notify_accounts_changed(pool: asyncpg.Pool) -> None:
await pool.execute("SELECT pg_notify($1, '')", ACCOUNTS_CHANGED_CHANNEL)
+111 -58
View File
@@ -1,5 +1,6 @@
import asyncpg
from utils.policy.models import FolderSpec
from utils.read.accounts import self_user_id
from utils.read.message_view import build_message_view, load_raw, media_ref_from
from utils.read.models import (
@@ -43,67 +44,88 @@ def _single_media(
return [ref] if ref else []
def _peer_title(
first: str | None, last: str | None, username: str | None
) -> str | None:
name = " ".join(part for part in (first, last) if part)
return name or username
_ALL_IDS = """
SELECT chat_id FROM chat_stats WHERE account_id = $1
UNION
SELECT chat_id FROM dialogs WHERE account_id = $1
UNION
SELECT scope_id AS chat_id FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id IS NOT NULL
"""
_ONE_ID = """
SELECT chat_id FROM chat_stats WHERE account_id = $1 AND chat_id = $2
UNION
SELECT chat_id FROM dialogs WHERE account_id = $1 AND chat_id = $2
UNION
SELECT scope_id AS chat_id FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
"""
_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,
COALESCE(named.title,
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
p.username) AS title,
COALESCE(typed.is_broadcast, false) AS is_broadcast,
COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool,
p.raw->>'type' = 'ChatType.BOT', false) AS is_bot,
COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool,
false) AS is_contact,
EXISTS (SELECT 1 FROM avatars a
WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar
FROM ids
LEFT JOIN chat_stats cs ON cs.account_id = $1 AND cs.chat_id = ids.chat_id
LEFT JOIN peers p ON p.account_id = $1 AND p.peer_id = ids.chat_id
LEFT JOIN LATERAL (
SELECT ch.title FROM chat_history ch
WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id AND ch.title IS NOT NULL
ORDER BY ch.ts DESC LIMIT 1
) named ON true
LEFT JOIN LATERAL (
SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type')
= 'ChatType.CHANNEL' AS is_broadcast
FROM chat_history ch
WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id
AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') IS NOT NULL
ORDER BY ch.ts DESC LIMIT 1
) typed ON true
"""
async def list_chats(
pool: asyncpg.Pool, account_id: int, page: Page
) -> list[ChatListItem]:
rows = await pool.fetch(
"WITH ids AS ("
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1), "
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date "
"FROM messages WHERE account_id = $1 GROUP BY chat_id) "
"SELECT ids.chat_id, COALESCE(agg.message_count, 0) AS message_count, "
"agg.last_date AS last_date, "
"(SELECT p.first_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS first_name, "
"(SELECT p.last_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS last_name, "
"(SELECT p.username FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS username, "
"(SELECT ch.title FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title, "
"EXISTS (SELECT 1 FROM avatars a "
"WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar, "
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, "
"p.raw->>'type' = 'ChatType.BOT', false) "
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_bot, "
"(SELECT COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, "
"false) FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_contact, "
"(SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') "
"= 'ChatType.CHANNEL' FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
"AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') IS NOT NULL "
"ORDER BY ch.ts DESC LIMIT 1) AS is_broadcast, "
"(SELECT lm.text FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_text, "
"(SELECT lm.sender_id FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_sender_id "
"FROM ids LEFT JOIN agg ON agg.chat_id = ids.chat_id "
"ORDER BY last_date DESC NULLS LAST, ids.chat_id DESC LIMIT $2 OFFSET $3",
account_id,
page.capped_limit,
page.offset,
def _folder_filter(base: int) -> str:
return (
f"NOT (chat.chat_id = ANY(${base + 1}::bigint[])) "
f"AND (chat.chat_id = ANY(${base + 2}::bigint[]) "
f"OR (NOT ${base + 3}::bool AND CASE "
f"WHEN chat.is_broadcast THEN ${base + 4}::bool "
f"WHEN chat.chat_id < 0 THEN ${base + 5}::bool "
f"WHEN chat.is_bot THEN ${base + 6}::bool "
f"WHEN chat.is_contact THEN ${base + 7}::bool "
f"ELSE ${base + 8}::bool END))"
)
items = []
for row in rows:
title = row["group_title"] or _peer_title(
row["first_name"], row["last_name"], row["username"]
)
items.append(
ChatListItem(
def _folder_params(folder: FolderSpec) -> list[object]:
return [
sorted(folder.exclude_ids),
sorted(folder.include_ids | folder.pinned_ids),
folder.is_chatlist,
folder.broadcasts,
folder.groups,
folder.bots,
folder.contacts,
folder.non_contacts,
]
def _chat_item(row: asyncpg.Record) -> ChatListItem:
return ChatListItem(
chat_id=row["chat_id"],
title=title,
title=row["title"],
kind="private" if row["chat_id"] > 0 else "group",
has_avatar=row["has_avatar"],
is_bot=bool(row["is_bot"]),
@@ -114,8 +136,39 @@ async def list_chats(
last_text=row["last_text"],
last_sender_id=row["last_sender_id"],
)
async def list_chats(
pool: asyncpg.Pool,
account_id: int,
page: Page,
*,
folder: FolderSpec | None = None,
search: str | None = None,
) -> list[ChatListItem]:
params: list[object] = [account_id, page.capped_limit, page.offset]
rows_sql = _CHAT_ROWS.format(ids=_ALL_IDS)
clauses: list[str] = []
if folder is not None:
clauses.append(_folder_filter(len(params)))
params.extend(_folder_params(folder))
if search:
params.append(f"%{search}%")
clauses.append(f"chat.title ILIKE ${len(params)}")
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
query = (
f"SELECT chat.* FROM ({rows_sql}) chat{where} " # noqa: S608
"ORDER BY last_date DESC NULLS LAST, chat_id DESC LIMIT $2 OFFSET $3"
)
return items
rows = await pool.fetch(query, *params)
return [_chat_item(row) for row in rows]
async def get_chat(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> ChatListItem | None:
row = await pool.fetchrow(_CHAT_ROWS.format(ids=_ONE_ID), account_id, chat_id)
return _chat_item(row) if row is not None else None
async def get_chat_history( # noqa: PLR0913
+141
View File
@@ -0,0 +1,141 @@
import asyncpg
from utils.policy.models import ChatKind
from utils.read.models import DiscoverItem
_ESCAPE = str.maketrans({"\\": "\\\\", "%": r"\%", "_": r"\_"})
_IS_BROADCAST = """
SELECT COALESCE(raw->'chat'->>'type', raw->>'type') = 'ChatType.CHANNEL'
FROM chat_history
WHERE account_id = $1 AND chat_id = $2
AND COALESCE(raw->'chat'->>'type', raw->>'type') IS NOT NULL
ORDER BY ts DESC LIMIT 1
"""
_IS_TRACKED = """
SELECT EXISTS (
SELECT 1 FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
)
"""
_ITEMS = """
WITH chat_meta AS (
SELECT DISTINCT ON (chat_id) chat_id, title,
COALESCE(raw->'chat'->>'username', raw->>'username') AS username,
COALESCE(raw->'chat'->>'type', raw->>'type') = 'ChatType.CHANNEL'
AS is_broadcast
FROM chat_history
WHERE account_id = $1 AND title IS NOT NULL
ORDER BY chat_id, ts DESC
), hits AS (
SELECT p.peer_id AS chat_id,
COALESCE(NULLIF(concat_ws(' ', p.first_name, p.last_name), ''), p.username)
AS title,
p.username,
COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, false) AS is_bot,
COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, false)
AS is_contact,
false AS is_broadcast
FROM peers p
WHERE p.account_id = $1 AND (p.peer_id = ANY($3::bigint[]) OR ($2 <> '' AND (
concat_ws(' ', p.first_name, p.last_name) ILIKE $2
OR p.username ILIKE $2 OR p.phone ILIKE $2)))
UNION ALL
SELECT c.chat_id, c.title, c.username, false, false,
COALESCE(c.is_broadcast, false)
FROM chat_meta c
WHERE c.chat_id = ANY($3::bigint[])
OR ($2 <> '' AND (c.title ILIKE $2 OR c.username ILIKE $2))
), merged AS (
SELECT chat_id, max(title) AS title, max(username) AS username,
bool_or(is_bot) AS is_bot, bool_or(is_contact) AS is_contact,
bool_or(is_broadcast) AS is_broadcast
FROM hits GROUP BY chat_id
), counts AS (
SELECT chat_id, count(*) AS message_count FROM messages
WHERE account_id = $1 AND chat_id IN (SELECT chat_id FROM merged)
GROUP BY chat_id
)
SELECT m.chat_id, m.title, m.username, m.is_bot, m.is_contact, m.is_broadcast,
COALESCE(c.message_count, 0) AS message_count,
EXISTS (SELECT 1 FROM avatars a
WHERE a.account_id = $1 AND a.owner_id = m.chat_id) AS has_avatar,
EXISTS (SELECT 1 FROM dialogs d
WHERE d.account_id = $1 AND d.chat_id = m.chat_id) AS in_dialogs,
EXISTS (SELECT 1 FROM capture_policy cp WHERE cp.account_id = $1
AND cp.scope_type = 'chat' AND cp.scope_id = m.chat_id) AS tracked
FROM merged m LEFT JOIN counts c ON c.chat_id = m.chat_id
ORDER BY in_dialogs DESC, message_count DESC, is_contact DESC, m.title
LIMIT $4
"""
def _kind(chat_id: int, *, is_broadcast: bool) -> str:
if chat_id > 0:
return "private"
return "channel" if is_broadcast else "group"
def _to_item(row: asyncpg.Record) -> DiscoverItem:
return DiscoverItem(
chat_id=row["chat_id"],
title=row["title"],
username=row["username"],
kind=_kind(row["chat_id"], is_broadcast=row["is_broadcast"]),
is_bot=row["is_bot"],
is_contact=row["is_contact"],
has_avatar=row["has_avatar"],
message_count=row["message_count"],
in_dialogs=row["in_dialogs"],
tracked=row["tracked"],
)
async def search(
pool: asyncpg.Pool, account_id: int, query: str, limit: int
) -> list[DiscoverItem]:
text = query.strip()
if not text:
return []
rows = await pool.fetch(
_ITEMS, account_id, f"%{text.translate(_ESCAPE)}%", [], limit
)
return [_to_item(row) for row in rows]
async def by_ids(
pool: asyncpg.Pool, account_id: int, ids: list[int]
) -> list[DiscoverItem]:
if not ids:
return []
rows = await pool.fetch(_ITEMS, account_id, "", ids, len(ids))
by_id = {row["chat_id"]: _to_item(row) for row in rows}
return [by_id[chat_id] for chat_id in ids if chat_id in by_id]
async def get_item(pool: asyncpg.Pool, account_id: int, chat_id: int) -> DiscoverItem:
known = await by_ids(pool, account_id, [chat_id])
if known:
return known[0]
kind = await chat_kind(pool, account_id, chat_id)
return DiscoverItem(
chat_id=chat_id,
title=None,
username=None,
kind="private" if kind is ChatKind.DM else kind.value,
is_bot=False,
is_contact=False,
has_avatar=False,
message_count=0,
in_dialogs=False,
tracked=bool(await pool.fetchval(_IS_TRACKED, account_id, chat_id)),
)
async def chat_kind(pool: asyncpg.Pool, account_id: int, chat_id: int) -> ChatKind:
if chat_id > 0:
return ChatKind.DM
is_broadcast = await pool.fetchval(_IS_BROADCAST, account_id, chat_id)
return ChatKind.CHANNEL if is_broadcast else ChatKind.GROUP
+34 -8
View File
@@ -1,18 +1,41 @@
import asyncpg
from utils.files import media_file_name
from utils.read.message_view import load_raw
from utils.read.models import MediaVersionView, MediaView
_MEDIA_COLS = (
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
"mime, ttl_seconds, downloaded, extracted_text, created_at"
MEDIA_COLS = (
"m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, "
"m.file_size, m.mime, m.ttl_seconds, m.downloaded, m.extracted_text, "
"m.created_at, src.original_name"
)
ORIGINAL_NAME_JOIN = """
LEFT JOIN LATERAL (
SELECT msg.raw->m.kind->>'file_name' AS original_name
FROM messages msg
WHERE msg.account_id = m.account_id AND msg.chat_id = m.chat_id
AND msg.message_id = m.message_id
ORDER BY msg.date DESC LIMIT 1
) src ON true
"""
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
_WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio")
def media_view(row: asyncpg.Record) -> MediaView:
fields = dict(row)
original = fields.pop("original_name", None)
return MediaView(
**fields,
file_name=media_file_name(
fields["kind"], fields["mime"], fields["message_id"], original
),
)
async def _web_page_media_stub(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MediaView | None:
@@ -47,29 +70,32 @@ async def _web_page_media_stub(
downloaded=False,
extracted_text=None,
created_at=row["date"],
file_name=media_file_name(
kind, obj.get("mime_type"), message_id, obj.get("file_name")
),
)
async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
row = await pool.fetchrow(
f"SELECT {_MEDIA_COLS} FROM media WHERE id = $1", # noqa: S608
f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} WHERE m.id = $1", # noqa: S608
media_id,
)
return MediaView(**dict(row)) if row else None
return media_view(row) if row else None
async def get_message_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MediaView | None:
row = await pool.fetchrow(
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} " # noqa: S608
"WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3",
account_id,
chat_id,
message_id,
)
if row is not None:
return MediaView(**dict(row))
return media_view(row)
return await _web_page_media_stub(pool, account_id, chat_id, message_id)
+4 -1
View File
@@ -5,6 +5,7 @@ from typing import Any
import asyncpg
from pydantic import ValidationError
from utils.files import media_file_name
from utils.read.models import (
ContactView,
EntityView,
@@ -344,6 +345,7 @@ def media_ref_from(
obj = obj if isinstance(obj, dict) else {}
width = obj.get("width") or obj.get("length")
height = obj.get("height") or obj.get("length")
mime = (media_row["mime"] if media_row else None) or obj.get("mime_type")
return MediaRef(
message_id=message_id,
id=media_row["id"] if media_row else None,
@@ -352,11 +354,12 @@ def media_ref_from(
width=width,
height=height,
duration=obj.get("duration"),
mime=(media_row["mime"] if media_row else None) or obj.get("mime_type"),
mime=mime,
file_size=(media_row["file_size"] if media_row else None)
or obj.get("file_size"),
ttl_seconds=media_row["ttl_seconds"] if media_row else None,
extracted_text=media_row["extracted_text"] if media_row else None,
file_name=media_file_name(kind, mime, message_id, obj.get("file_name")),
)
+46
View File
@@ -22,6 +22,7 @@ class AccountView(BaseModel):
phone: str | None
tg_user_id: int | None
is_active: bool
device_model: str | None
class ChatListItem(BaseModel):
@@ -38,6 +39,19 @@ class ChatListItem(BaseModel):
last_sender_id: int | None
class DiscoverItem(BaseModel):
chat_id: int
title: str | None
username: str | None
kind: str
is_bot: bool
is_contact: bool
has_avatar: bool
message_count: int
in_dialogs: bool
tracked: bool
class EntityView(BaseModel):
type: str
offset: int
@@ -78,6 +92,7 @@ class MediaRef(BaseModel):
file_size: int | None = None
ttl_seconds: int | None = None
extracted_text: str | None = None
file_name: str | None = None
class ReactionCount(BaseModel):
@@ -208,6 +223,7 @@ class MediaView(BaseModel):
downloaded: bool
extracted_text: str | None
created_at: datetime
file_name: str | None = None
class MediaVersionView(BaseModel):
@@ -377,3 +393,33 @@ class AlertView(BaseModel):
payload: dict[str, Any]
seen: bool
created_at: datetime
class FileShareView(BaseModel):
id: int
account_id: int
token: str
kind: str
file_name: str
url_name: str
mime: str | None
file_size: int | None
title: str | None
chat_id: int | None
message_id: int | None
peer_id: int | None
story_id: int | None
expires_at: datetime | None
max_downloads: int | None
download_count: int
last_download_at: datetime | None
revoked_at: datetime | None
created_at: datetime
class FileShareHitView(BaseModel):
ts: datetime
method: str
ip: str | None
user_agent: str | None
counted: bool
+5 -9
View File
@@ -3,28 +3,24 @@ from datetime import datetime, timedelta
import asyncpg
from utils.read.accounts import self_user_id
from utils.read.media import MEDIA_COLS, ORIGINAL_NAME_JOIN, media_view
from utils.read.models import ChatLinkView, DayCount, MediaView, MessageAt, Page
_MEDIA_COLS = (
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
"mime, ttl_seconds, downloaded, extracted_text, created_at"
)
async def chat_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, kinds: list[str], page: Page
) -> list[MediaView]:
rows = await pool.fetch(
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND kind = ANY($3) "
"ORDER BY message_id DESC LIMIT $4 OFFSET $5",
f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} " # noqa: S608
"WHERE m.account_id = $1 AND m.chat_id = $2 AND m.kind = ANY($3) "
"ORDER BY m.message_id DESC LIMIT $4 OFFSET $5",
account_id,
chat_id,
kinds,
page.capped_limit,
page.offset,
)
return [MediaView(**dict(row)) for row in rows]
return [media_view(row) for row in rows]
async def chat_links(
+207
View File
@@ -0,0 +1,207 @@
import secrets
from datetime import datetime
import asyncpg
from utils.files import url_slug
from utils.read.models import FileShareHitView, FileShareView, Page
_COLS = (
"id, account_id, token, kind, file_name, mime, file_size, title, "
"chat_id, message_id, peer_id, story_id, expires_at, max_downloads, "
"download_count, last_download_at, revoked_at, created_at"
)
_TOKEN_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789" # noqa: S105
_TOKEN_LENGTH = 10
_SERVE = """
SELECT id, kind, storage_key, file_name, mime, expires_at, max_downloads,
download_count, revoked_at
FROM file_shares WHERE token = $1
"""
_INSERT = (
"INSERT INTO file_shares " # noqa: S608
"(account_id, token, kind, storage_key, file_name, mime, file_size, title, "
"chat_id, message_id, peer_id, story_id, expires_at, max_downloads) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) "
f"RETURNING {_COLS}"
)
_ACTIVE = "revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())"
_LOOKUP = (
f"SELECT {_COLS} FROM file_shares " # noqa: S608
f"WHERE account_id = $1 AND storage_key = $2 AND {_ACTIVE} "
"AND (max_downloads IS NULL OR download_count < max_downloads) "
"ORDER BY created_at DESC LIMIT 1"
)
_CONSUME = """
UPDATE file_shares
SET download_count = download_count + 1, last_download_at = now()
WHERE id = $1 AND (max_downloads IS NULL OR download_count < max_downloads)
RETURNING download_count
"""
def new_token() -> str:
return "".join(secrets.choice(_TOKEN_ALPHABET) for _ in range(_TOKEN_LENGTH))
def _view(row: asyncpg.Record) -> FileShareView:
fields = dict(row)
return FileShareView(**fields, url_name=url_slug(fields["file_name"]))
async def list_shares(
pool: asyncpg.Pool, account_id: int, page: Page, *, active_only: bool = False
) -> list[FileShareView]:
where = "account_id = $1"
if active_only:
where += f" AND {_ACTIVE}"
rows = await pool.fetch(
f"SELECT {_COLS} FROM file_shares WHERE {where} " # noqa: S608
"ORDER BY created_at DESC LIMIT $2 OFFSET $3",
account_id,
page.capped_limit,
page.offset,
)
return [_view(row) for row in rows]
async def get_share(pool: asyncpg.Pool, share_id: int) -> FileShareView | None:
row = await pool.fetchrow(
f"SELECT {_COLS} FROM file_shares WHERE id = $1", # noqa: S608
share_id,
)
return _view(row) if row else None
async def find_active_share(
pool: asyncpg.Pool, account_id: int, storage_key: str
) -> FileShareView | None:
row = await pool.fetchrow(_LOOKUP, account_id, storage_key)
return _view(row) if row else None
async def create_share( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
kind: str,
storage_key: str,
file_name: str,
*,
mime: str | None = None,
file_size: int | None = None,
title: str | None = None,
chat_id: int | None = None,
message_id: int | None = None,
peer_id: int | None = None,
story_id: int | None = None,
expires_at: datetime | None = None,
max_downloads: int | None = None,
) -> FileShareView:
row = await pool.fetchrow(
_INSERT,
account_id,
new_token(),
kind,
storage_key,
file_name,
mime,
file_size,
title,
chat_id,
message_id,
peer_id,
story_id,
expires_at,
max_downloads,
)
return _view(row)
async def update_share(
pool: asyncpg.Pool,
share_id: int,
*,
expires_at: datetime | None,
max_downloads: int | None,
) -> FileShareView | None:
row = await pool.fetchrow(
"UPDATE file_shares SET expires_at = $2, max_downloads = $3 " # noqa: S608
f"WHERE id = $1 RETURNING {_COLS}",
share_id,
expires_at,
max_downloads,
)
return _view(row) if row else None
async def revoke_share(pool: asyncpg.Pool, share_id: int) -> FileShareView | None:
row = await pool.fetchrow(
"UPDATE file_shares SET revoked_at = now() " # noqa: S608
f"WHERE id = $1 AND revoked_at IS NULL RETURNING {_COLS}",
share_id,
)
if row is not None:
return _view(row)
return await get_share(pool, share_id)
async def delete_share(pool: asyncpg.Pool, share_id: int) -> bool:
result = await pool.execute("DELETE FROM file_shares WHERE id = $1", share_id)
return result.endswith("1")
async def rotate_token(pool: asyncpg.Pool, share_id: int) -> FileShareView | None:
row = await pool.fetchrow(
"UPDATE file_shares SET token = $2, revoked_at = NULL " # noqa: S608
f"WHERE id = $1 RETURNING {_COLS}",
share_id,
new_token(),
)
return _view(row) if row else None
async def share_by_token(pool: asyncpg.Pool, token: str) -> asyncpg.Record | None:
return await pool.fetchrow(_SERVE, token)
async def consume_download(pool: asyncpg.Pool, share_id: int) -> bool:
return await pool.fetchval(_CONSUME, share_id) is not None
async def record_hit( # noqa: PLR0913
pool: asyncpg.Pool,
share_id: int,
method: str,
ip: str | None,
user_agent: str | None,
*,
counted: bool,
) -> None:
await pool.execute(
"INSERT INTO file_share_hits (share_id, method, ip, user_agent, counted) "
"VALUES ($1, $2, $3, $4, $5)",
share_id,
method,
ip,
user_agent,
counted,
)
async def list_hits(
pool: asyncpg.Pool, share_id: int, page: Page
) -> list[FileShareHitView]:
rows = await pool.fetch(
"SELECT ts, method, ip, user_agent, counted FROM file_share_hits "
"WHERE share_id = $1 ORDER BY ts DESC LIMIT $2 OFFSET $3",
share_id,
page.capped_limit,
page.offset,
)
return [FileShareHitView(**dict(row)) for row in rows]
+3
View File
@@ -1 +1,4 @@
BEAVERGRAM_DOMAIN=beavergram.localhost
BEAVERGRAM_DEV_DOMAIN=dev.beavergram.localhost
CLOUDFLARE_API_TOKEN=
+3 -5
View File
@@ -12,10 +12,8 @@
}
}
<DOMAIN> {
reverse_proxy beavergram-api:8080
(compress) {
encode zstd gzip
}
dev.<DOMAIN> {
reverse_proxy beavergram-frontend:5173
}
import /etc/caddy/projects.d/*.caddy
+4 -3
View File
@@ -8,11 +8,12 @@ services:
- "0.0.0.0:443:443/udp"
networks:
- caddy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
env_file:
- .env
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./site.caddy:/etc/caddy/projects.d/beavergram.caddy:ro
- caddy_data:/data
networks:
caddy:
+9
View File
@@ -0,0 +1,9 @@
{$BEAVERGRAM_DOMAIN} {
encode zstd gzip
reverse_proxy beavergram-api:8080
}
{$BEAVERGRAM_DEV_DOMAIN} {
encode zstd gzip
reverse_proxy beavergram-frontend:5173
}
+1
View File
@@ -52,6 +52,7 @@ services:
restart: unless-stopped
volumes:
- ./backend/src:/app/src
- ./backend/sessions:/app/sessions
- ./frontend/build:/app/static:ro
- ${STORAGE__ROOT:-./storage}:/app/storage
depends_on:
+3
View File
@@ -8,6 +8,7 @@
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^2.1.0",
"uqr": "^0.1.3",
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
@@ -388,6 +389,8 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
+2 -1
View File
@@ -34,6 +34,7 @@
"dependencies": {
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^2.1.0"
"pako": "^2.1.0",
"uqr": "^0.1.3"
}
}
+19 -2
View File
@@ -6,6 +6,8 @@ const RETRY_DELAY = 2500;
export type AvatarKind = "peer" | "chat";
const MAX_CACHED = 240;
const ready = new Map<string, string>();
const missing = new Set<string>();
const inflight = new Map<string, Promise<string | null>>();
@@ -14,6 +16,21 @@ function cacheKey(account: number, kind: AvatarKind, id: number): string {
return `${account}:${kind}:${id}`;
}
function remember(key: string, url: string) {
ready.set(key, url);
while (ready.size > MAX_CACHED) {
const oldest = ready.keys().next();
if (oldest.done) {
return;
}
const stale = ready.get(oldest.value);
ready.delete(oldest.value);
if (stale) {
URL.revokeObjectURL(stale);
}
}
}
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
@@ -35,7 +52,7 @@ async function fetchAvatar(
const response = await fetch(url, { headers: authHeaders() });
if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl);
remember(key, objectUrl);
return objectUrl;
}
if (response.status === 409 && retry) {
@@ -85,7 +102,7 @@ async function fetchVariant(
const response = await fetch(url, { headers: authHeaders() });
if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl);
remember(key, objectUrl);
return objectUrl;
}
if (response.status === 409 && retry) {
+61
View File
@@ -0,0 +1,61 @@
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const FILENAME_STAR = /filename\*=UTF-8''([^;]+)/i;
const FILENAME_PLAIN = /filename="?([^";]+)"?/i;
function nameFromHeader(header: string | null, fallback: string): string {
if (!header) {
return fallback;
}
const encoded = FILENAME_STAR.exec(header);
if (encoded) {
return decodeURIComponent(encoded[1]);
}
const plain = FILENAME_PLAIN.exec(header);
return plain ? plain[1] : fallback;
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function save(path: string, fallback: string): Promise<void> {
const response = await fetch(`${BASE}${path}`, {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
});
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const fileName = nameFromHeader(
response.headers.get("content-disposition"),
fallback
);
saveBlob(await response.blob(), fileName);
}
export function downloadMedia(mediaId: number): Promise<void> {
return save(`/media/${mediaId}?download=true`, `media_${mediaId}`);
}
export function downloadMediaVersion(versionId: number): Promise<void> {
return save(
`/media/version/${versionId}?download=true`,
`media_${versionId}`
);
}
export function downloadStory(peerId: number, storyId: number): Promise<void> {
return save(
`/stories/${peerId}/${storyId}/media?download=true&account_id=${accounts.selectedId}`,
`story_${peerId}_${storyId}`
);
}
+110 -4
View File
@@ -9,10 +9,12 @@ import type {
Chat,
ChatLinkView,
DayCount,
DiscoverItem,
Folder,
JobStatus,
JobView,
LinkView,
LoginState,
MediaVersion,
MediaView,
MessageAt,
@@ -44,10 +46,72 @@ export function listAccounts(): Promise<Account[]> {
return request<Account[]>("/accounts");
}
export function listChats(page: Page = {}): Promise<Chat[]> {
export function startLogin(phone: string): Promise<LoginState> {
return request<LoginState>("/accounts/login", {
method: "POST",
body: { phone },
});
}
export function startQrLogin(): Promise<LoginState> {
return request<LoginState>("/accounts/login/qr", { method: "POST" });
}
export function pollQrLogin(loginId: string): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/qr`);
}
export function submitLoginCode(
loginId: string,
code: string
): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/code`, {
method: "POST",
body: { code },
});
}
export function submitLoginPassword(
loginId: string,
password: string
): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/password`, {
method: "POST",
body: { password },
});
}
export function cancelLogin(loginId: string): Promise<void> {
return request<void>(`/accounts/login/${loginId}`, { method: "DELETE" });
}
export function renameAccountDevice(
accountId: number,
deviceModel: string
): Promise<Account> {
return request<Account>(`/accounts/${accountId}/device`, {
method: "PATCH",
body: { device_model: deviceModel },
});
}
export function logoutAccount(accountId: number): Promise<void> {
return request<void>(`/accounts/${accountId}`, { method: "DELETE" });
}
interface ChatPage extends Page {
folder_id?: number;
search?: string;
}
export function listChats(page: ChatPage = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } });
}
export function getChat(chatId: number): Promise<Chat | null> {
return request<Chat | null>(`/chats/${chatId}`, { account: true });
}
export function listFolders(): Promise<Folder[]> {
return request<Folder[]>("/folders", { account: true });
}
@@ -69,6 +133,7 @@ export function updatePolicy(
): Promise<PolicyRecord> {
return request<PolicyRecord>(`/policy/${id}`, {
method: "PUT",
account: true,
body: toggles,
});
}
@@ -250,7 +315,7 @@ export function getMessageAt(chatId: number, date: string): Promise<MessageAt> {
}
export function getStories(
peerId: number,
peerId: number | null,
page: Page = {}
): Promise<StoryView[]> {
return request<StoryView[]>("/stories", {
@@ -293,11 +358,52 @@ export function listJobs(status?: JobStatus): Promise<JobView[]> {
export function enqueueBackfill(
chatId: number,
media: boolean
media: boolean,
full = false
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/backfill", {
method: "POST",
body: { account_id: accounts.selectedId, chat_id: chatId, media },
body: { account_id: accounts.selectedId, chat_id: chatId, media, full },
});
}
export function enqueueStoriesBackfill(
peerId: number
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/stories/backfill", {
method: "POST",
body: { account_id: accounts.selectedId, peer_id: peerId },
});
}
export function discoverPeers(
query: string,
remote = false
): Promise<DiscoverItem[]> {
return request<DiscoverItem[]>("/discover", {
account: true,
query: { query, remote },
});
}
export function getDiscoverItem(chatId: number): Promise<DiscoverItem> {
return request<DiscoverItem>(`/discover/${chatId}`, { account: true });
}
export function trackChat(
chatId: number,
backfill = true
): Promise<DiscoverItem> {
return request<DiscoverItem>(`/chats/${chatId}/track`, {
method: "POST",
body: { account_id: accounts.selectedId, backfill },
});
}
export function syncContacts(): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/contacts/sync", {
method: "POST",
body: { account_id: accounts.selectedId },
});
}
+37 -1
View File
@@ -20,9 +20,12 @@ export type InlineMedia =
export interface ViewerItem {
downloaded: boolean;
fileName: string | null;
fileSize: number | null;
kind: string;
mediaId: number | null;
messageId: number;
mime: string | null;
}
export function viewerItemsFrom(
@@ -30,16 +33,49 @@ export function viewerItemsFrom(
media: MediaRef[]
): ViewerItem[] {
if (media.length === 0) {
return [{ messageId, mediaId: null, kind: "", downloaded: false }];
return [
{
messageId,
mediaId: null,
kind: "",
downloaded: false,
fileName: null,
fileSize: null,
mime: null,
},
];
}
return media.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
fileName: item.file_name,
fileSize: item.file_size,
mime: item.mime,
}));
}
const PREVIEW_KINDS = new Set([
"photo",
"video",
"video_note",
"animation",
"gif",
"sticker",
"voice",
"audio",
]);
const PREVIEW_MIME_PREFIXES = ["image/", "video/", "audio/"];
export function isPreviewable(kind: string, mime: string | null): boolean {
if (PREVIEW_KINDS.has(kind)) {
return true;
}
return PREVIEW_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix));
}
export type VisualKind = "image" | "video" | "other";
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
+119
View File
@@ -0,0 +1,119 @@
import { request } from "$lib/api/client";
import type { FileShare, FileShareHit, ShareSubject } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
export interface ShareSettings {
expiresInSeconds: number | null;
keepExpiry?: boolean;
maxDownloads: number | null;
}
function subjectQuery(subject: ShareSubject): Record<string, number | string> {
const query: Record<string, number | string> = { kind: subject.kind };
if (subject.mediaId !== undefined) {
query.media_id = subject.mediaId;
}
if (subject.versionId !== undefined) {
query.version_id = subject.versionId;
}
if (subject.peerId !== undefined) {
query.peer_id = subject.peerId;
}
if (subject.storyId !== undefined) {
query.story_id = subject.storyId;
}
return query;
}
export function listShares(activeOnly = false): Promise<FileShare[]> {
return request<FileShare[]>("/shares", {
account: true,
query: { active_only: activeOnly, limit: 200 },
});
}
export function lookupShare(subject: ShareSubject): Promise<FileShare | null> {
return request<FileShare | null>("/shares/lookup", {
account: true,
query: subjectQuery(subject),
});
}
export function createShare(
subject: ShareSubject,
settings: ShareSettings
): Promise<FileShare> {
return request<FileShare>("/shares", {
method: "POST",
body: {
account_id: accounts.selectedId,
kind: subject.kind,
media_id: subject.mediaId ?? null,
version_id: subject.versionId ?? null,
peer_id: subject.peerId ?? null,
story_id: subject.storyId ?? null,
expires_in_seconds: settings.expiresInSeconds,
max_downloads: settings.maxDownloads,
},
});
}
export function updateShare(
id: number,
settings: ShareSettings
): Promise<FileShare> {
return request<FileShare>(`/shares/${id}`, {
method: "PATCH",
body: {
expires_in_seconds: settings.expiresInSeconds,
keep_expiry: settings.keepExpiry ?? false,
max_downloads: settings.maxDownloads,
},
});
}
export function revokeShare(id: number): Promise<FileShare> {
return request<FileShare>(`/shares/${id}/revoke`, { method: "POST" });
}
export function reissueShare(id: number): Promise<FileShare> {
return request<FileShare>(`/shares/${id}/reissue`, { method: "POST" });
}
export function deleteShare(id: number): Promise<void> {
return request<void>(`/shares/${id}`, { method: "DELETE" });
}
export function listShareHits(id: number): Promise<FileShareHit[]> {
return request<FileShareHit[]>(`/shares/${id}/hits`, {
query: { limit: 50 },
});
}
const TRAILING_SLASH = /\/$/;
export function shareUrl(share: FileShare): string {
const origin =
typeof window === "undefined"
? ""
: window.location.origin.replace(TRAILING_SLASH, "");
return `${origin}/f/${share.token}/${share.url_name}`;
}
export function shareState(
share: FileShare
): "active" | "revoked" | "expired" | "exhausted" {
if (share.revoked_at) {
return "revoked";
}
if (share.expires_at && Date.parse(share.expires_at) <= Date.now()) {
return "expired";
}
if (
share.max_downloads !== null &&
share.download_count >= share.max_downloads
) {
return "exhausted";
}
return "active";
}
+65
View File
@@ -16,12 +16,22 @@ export type JobStatus =
export interface Account {
account_id: number;
device_model: string | null;
is_active: boolean;
label: string | null;
phone: string | null;
tg_user_id: number | null;
}
export type LoginStage = "code" | "qr" | "password" | "done";
export interface LoginState {
account: Account | null;
login_id: string;
qr_url: string | null;
stage: LoginStage;
}
export interface Chat {
chat_id: number;
has_avatar: boolean;
@@ -36,6 +46,21 @@ export interface Chat {
title: string | null;
}
export type DiscoverKind = "private" | "group" | "channel";
export interface DiscoverItem {
chat_id: number;
has_avatar: boolean;
in_dialogs: boolean;
is_bot: boolean;
is_contact: boolean;
kind: DiscoverKind;
message_count: number;
title: string | null;
tracked: boolean;
username: string | null;
}
export interface EntityView {
custom_emoji_id: string | null;
language: string | null;
@@ -67,6 +92,7 @@ export interface ForwardView {
export interface MediaRef {
downloaded: boolean;
duration: number | null;
file_name: string | null;
file_size: number | null;
height: number | null;
id: number | null;
@@ -217,6 +243,7 @@ export interface MediaView {
created_at: string;
downloaded: boolean;
extracted_text: string | null;
file_name: string | null;
file_size: number | null;
id: number;
kind: string;
@@ -481,3 +508,41 @@ export type LiveEvent =
| LiveDeleteEvent
| LivePresenceEvent
| LiveReceiptEvent;
export interface FileShare {
account_id: number;
chat_id: number | null;
created_at: string;
download_count: number;
expires_at: string | null;
file_name: string;
file_size: number | null;
id: number;
kind: string;
last_download_at: string | null;
max_downloads: number | null;
message_id: number | null;
mime: string | null;
peer_id: number | null;
revoked_at: string | null;
story_id: number | null;
title: string | null;
token: string;
url_name: string;
}
export interface FileShareHit {
counted: boolean;
ip: string | null;
method: string;
ts: string;
user_agent: string | null;
}
export interface ShareSubject {
kind: "media" | "media_version" | "story";
mediaId?: number;
peerId?: number;
storyId?: number;
versionId?: number;
}
@@ -1,87 +0,0 @@
<script lang="ts">
import { DropdownMenu } from "bits-ui";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
const current = $derived(accounts.selected);
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="account-trigger">
{#if current}
<Avatar
name={accountName(current)}
colorKey={current.account_id}
size={2.25}
/>
<span class="account-name">{accountName(current)}</span>
{:else}
<span class="account-name">No account</span>
{/if}
<Icon name="down" size="1.25rem" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="bg-menu-content" sideOffset={6} align="start">
{#each accounts.list as account (account.account_id)}
<DropdownMenu.Item
class="bg-menu-item"
data-selected={account.account_id === accounts.selectedId
? ""
: undefined}
onSelect={() => accounts.select(account.account_id)}
>
<Avatar
name={accountName(account)}
colorKey={account.account_id}
size={1.75}
/>
<span>{accountName(account)}</span>
{#if account.account_id === accounts.selectedId}
<Icon name="check" size="1.125rem" class="trailing" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<style lang="scss">
:global(.account-trigger) {
cursor: pointer;
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0.375rem 0.5rem;
border: 0;
border-radius: 0.625rem;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-chat-hover);
}
}
.account-name {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.bg-menu-item .trailing) {
margin-inline-start: auto;
color: var(--color-primary);
}
</style>
+34 -22
View File
@@ -12,10 +12,10 @@
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer";
import { formatPresence } from "$lib/format/presence";
import { formatPresence, isOnline } from "$lib/format/presence";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { events } from "$lib/stores/events.svelte";
import { discover } from "$lib/stores/discover.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
@@ -25,8 +25,11 @@
let { chatId }: Props = $props();
const PRESENCE_INTERVAL = 30_000;
const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId));
let peer = $state<PeerView | null>(null);
let presence = $state<PresenceSample | null>(null);
let backfilling = $state(false);
@@ -38,7 +41,7 @@
backfilling = true;
try {
await enqueueBackfill(chatId, true);
toasts.success("Бэкфилл запущен");
toasts.success("Догружаем новые сообщения");
} catch {
toasts.error("Не удалось запустить бэкфилл");
} finally {
@@ -46,6 +49,13 @@
}
}
$effect(() => {
if (accounts.selectedId === null) {
return;
}
discover.ensure(chatId).catch(() => undefined);
});
$effect(() => {
if (accounts.selectedId === null || !isDm) {
peer = null;
@@ -76,6 +86,10 @@
}
let active = true;
presence = null;
const refresh = () => {
if (document.visibilityState !== "visible") {
return;
}
getCurrentPresence(chatId)
.then((result) => {
if (active) {
@@ -87,23 +101,19 @@
presence = null;
}
});
const unsub = events.subscribe((event) => {
if (
event.type === "presence" &&
event.peer_id === chatId &&
event.sample
) {
presence = event.sample;
}
});
};
refresh();
const timer = setInterval(refresh, PRESENCE_INTERVAL);
return () => {
active = false;
unsub();
clearInterval(timer);
};
});
const fallbackTitle = $derived(
chat?.title ?? (isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
chat?.title ??
discovered?.title ??
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
);
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => {
@@ -116,11 +126,16 @@
}
return peer?.phone ?? `ID ${chatId}`;
}
const count = chat?.message_count ?? 0;
return count > 0 ? `${count} messages` : "group";
const count = chat?.message_count ?? discovered?.message_count ?? 0;
if (count > 0) {
return `${count} messages`;
}
return discovered?.kind === "channel" ? "channel" : "group";
});
const avatarKind = $derived(isDm ? "peer" : "chat");
const hasAvatar = $derived(chat?.has_avatar ?? Boolean(peer?.has_avatar));
const hasAvatar = $derived(
chat?.has_avatar ?? Boolean(peer?.has_avatar || discovered?.has_avatar)
);
</script>
<header class="chat-header">
@@ -154,10 +169,7 @@
/>
<div class="info">
<h2 class="title">{title}</h2>
<span
class="subtitle"
class:online={isDm && presence?.status === "online"}
>
<span class="subtitle" class:online={isDm && isOnline(presence)}>
{subtitle}
</span>
</div>
@@ -199,7 +211,7 @@
smaller
loading={backfilling}
onclick={backfill}
aria-label="Скачать историю"
aria-label="Догрузить новые сообщения"
>
<Icon name="cloud-download" />
</Button>
+78 -30
View File
@@ -1,12 +1,10 @@
<script lang="ts">
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import ChatListItem from "$lib/components/ChatListItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Skeleton from "$lib/components/ui/Skeleton.svelte";
import { folderContains } from "$lib/format/folders";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { folders } from "$lib/stores/folders.svelte";
@@ -14,37 +12,94 @@
const skeletonRows = Array.from({ length: 9 }, (_, index) => index);
const DEFAULT_ROW_HEIGHT = 72;
const OVERSCAN = 6;
const SCROLL_THRESHOLD = 600;
const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const selectedFolder = $derived(folders.selected);
const visibleChats = $derived(
selectedFolder === null
? chats.list
: chats.list.filter((chat) => folderContains(selectedFolder, chat))
);
let viewport = $state<HTMLDivElement | null>(null);
let viewportHeight = $state(0);
let scrollTop = $state(0);
let rowHeight = $state(DEFAULT_ROW_HEIGHT);
let frame = 0;
const SCROLL_THRESHOLD = 600;
const list = $derived(chats.list);
const start = $derived(
Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
);
const visible = $derived(
list.slice(
start,
start + Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2
)
);
const padTop = $derived(start * rowHeight);
const padBottom = $derived(
Math.max(0, (list.length - start - visible.length) * rowHeight)
);
$effect(() => {
if (accounts.selectedId === null) {
return;
}
chats.load().catch(() => toasts.error("Failed to load chats"));
folders.load().catch(() => toasts.error("Failed to load folders"));
untrack(() => folders.load()).catch(() =>
toasts.error("Failed to load folders")
);
});
$effect(() => {
const folderId = folders.selectedId;
if (accounts.selectedId === null) {
return;
}
if (viewport) {
viewport.scrollTop = 0;
scrollTop = 0;
}
untrack(() => chats.load(folderId)).catch(() =>
toasts.error("Failed to load chats")
);
});
$effect(() => {
if (visible.length === 0 || !viewport) {
return;
}
const row = viewport.querySelector<HTMLElement>(".Chat");
if (row && row.offsetHeight > 0 && row.offsetHeight !== rowHeight) {
rowHeight = row.offsetHeight;
}
});
function measure(el: HTMLElement) {
scrollTop = el.scrollTop;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
chats.loadMore(folders.selectedId).catch(() => undefined);
}
}
function onScroll(event: Event) {
const el = event.currentTarget as HTMLElement;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
chats.loadMore().catch(() => undefined);
if (frame) {
return;
}
frame = requestAnimationFrame(() => {
frame = 0;
measure(el);
});
}
</script>
<div class="chat-list custom-scroll" onscroll={onScroll}>
{#if chats.loading && chats.list.length === 0}
<div
bind:this={viewport}
bind:clientHeight={viewportHeight}
class="chat-list custom-scroll"
onscroll={onScroll}
>
{#if chats.loading && list.length === 0}
{#each skeletonRows as index (index)}
<div class="row-skeleton">
<Skeleton width="3rem" height="3rem" circle />
@@ -54,30 +109,23 @@
</div>
</div>
{/each}
{:else if chats.list.length === 0}
<EmptyState title="No chats yet" />
{:else}
{#key folders.selectedId}
<div
class="folder-view"
in:fly={{ x: folders.direction * 24, duration: 200, easing: cubicOut }}
>
{#if visibleChats.length === 0 && !chats.hasMore}
{:else if list.length === 0}
<EmptyState
title="Empty folder"
description="No chats match this folder yet"
title={folders.selectedId === null ? "No chats yet" : "Empty folder"}
description={folders.selectedId === null
? undefined
: "No chats match this folder yet"}
/>
{:else}
{#each visibleChats as chat (chat.chat_id)}
<div style:padding-top="{padTop}px" style:padding-bottom="{padBottom}px">
{#each visible as chat (chat.chat_id)}
<ChatListItem
{chat}
selected={chat.chat_id === activeChatId}
onclick={() => goto(`/app/${chat.chat_id}`)}
/>
{/each}
{/if}
</div>
{/key}
{/if}
</div>
@@ -119,6 +119,7 @@
gap: 0.625rem;
width: 100%;
height: 4.5rem;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
+20 -1
View File
@@ -69,13 +69,14 @@
{:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"}
<a
class="link"
class:own
href={linkHref(node)}
target="_blank"
rel="noopener noreferrer"
>{@render tree(node.children)}</a
>
{:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"}
<span class="link">{@render tree(node.children)}</span>
<span class="link" class:own>{@render tree(node.children)}</span>
{:else}
{@render tree(node.children)}
{/if}
@@ -108,6 +109,24 @@
&:hover {
text-decoration: underline;
}
&.own {
color: var(--color-own-links);
}
}
a.link.own {
text-decoration: underline;
text-decoration-color: color-mix(
in srgb,
var(--color-own-links) 45%,
transparent
);
text-underline-offset: 0.15em;
&:hover {
text-decoration-color: currentcolor;
}
}
.code,
+23 -1
View File
@@ -1,14 +1,21 @@
<script lang="ts">
import { isPreviewable } from "$lib/api/media";
import type { MediaRef } from "$lib/api/types";
import AlbumTile from "$lib/components/media/AlbumTile.svelte";
import FileChip from "$lib/components/media/FileChip.svelte";
interface Props {
chatId: number;
media: MediaRef[];
onopen: (index: number) => void;
own?: boolean;
}
let { media, chatId, onopen }: Props = $props();
let { media, chatId, onopen, own = false }: Props = $props();
const asFiles = $derived(
media.every((item) => !isPreviewable(item.kind, item.mime))
);
const columns = $derived.by(() => {
const count = media.length;
@@ -22,13 +29,28 @@
});
</script>
{#if asFiles}
<div class="AlbumFiles">
{#each media as item, index (item.id ?? index)}
<FileChip media={item} {own} />
{/each}
</div>
{:else}
<div class="MediaAlbum" style:--cols={columns}>
{#each media as item, index (item.id ?? index)}
<AlbumTile media={item} {chatId} onopen={() => onopen(index)} />
{/each}
</div>
{/if}
<style lang="scss">
.AlbumFiles {
display: flex;
flex-direction: column;
gap: 0.125rem;
margin-bottom: 0.25rem;
}
.MediaAlbum {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
@@ -4,6 +4,7 @@
import type { MediaVersion } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { poster } from "$lib/media/poster";
interface Props {
version: MediaVersion;
@@ -37,7 +38,13 @@
</a>
{:else if result.state === "ready" && vk === "video"}
<a href={result.url} target="_blank" rel="noopener">
<video src={result.url} muted preload="metadata"></video>
<video
src={result.url}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
</a>
{:else if result.state === "ready"}
+196 -22
View File
@@ -2,11 +2,17 @@
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "$lib/api/client";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
import type { ViewerItem } from "$lib/api/media";
import { isPreviewable, type ViewerItem } from "$lib/api/media";
import Button from "$lib/components/ui/Button.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";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
@@ -25,8 +31,13 @@
let kind = $state("");
let messageId = $state<number | null>(null);
let currentMediaId = $state<number | null>(null);
let fileName = $state<string | null>(null);
let fileSize = $state<number | null>(null);
let fileOnly = $state(false);
let result = $state<MediaResult | null>(null);
let loading = $state(false);
let saving = $state(false);
let token = 0;
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
@@ -38,9 +49,13 @@
mime.startsWith("audio/") || kind === "voice" || kind === "audio"
);
const hasNav = $derived(items.length > 1);
const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready")
);
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
function revoke() {
if (result?.state === "ready") {
if (result?.state === "ready" && result.url) {
URL.revokeObjectURL(result.url);
}
}
@@ -51,21 +66,41 @@
result = null;
kind = item.kind;
messageId = item.messageId;
currentMediaId = null;
fileName = item.fileName;
fileSize = item.fileSize;
fileOnly = false;
const current = ++token;
try {
let mediaId = item.mediaId;
let downloaded = item.downloaded;
let name = item.fileName;
let size = item.fileSize;
let mimeType = item.mime;
if (mediaId === null) {
const meta = await getMessageMedia(chatId, item.messageId);
mediaId = meta.id;
downloaded = meta.downloaded;
kind = meta.kind;
name = meta.file_name;
size = meta.file_size;
mimeType = meta.mime;
}
const plainFile = downloaded && !isPreviewable(kind, mimeType);
let next: MediaResult;
if (plainFile) {
next = { state: "ready", url: "", mime: mimeType };
} else if (downloaded) {
next = await requestMedia(mediaId);
} else {
next = { state: "not-downloaded" } as MediaResult;
}
const next = downloaded
? await requestMedia(mediaId)
: ({ state: "not-downloaded" } as MediaResult);
if (current === token) {
result = next;
currentMediaId = mediaId;
fileName = name;
fileSize = size;
fileOnly = plainFile;
}
} catch {
if (current === token) {
@@ -84,9 +119,29 @@
}
try {
await fetchMedia(chatId, messageId);
toasts.success("Download queued");
toasts.success("Скачивание поставлено в очередь");
} catch {
toasts.error("Failed to queue download");
toasts.error("Не удалось поставить в очередь");
}
}
async function save() {
if (currentMediaId === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(currentMediaId);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (currentMediaId !== null) {
shareUi.share({ kind: "media", mediaId: currentMediaId }, title);
}
}
@@ -134,10 +189,43 @@
<Dialog.Portal>
<Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content">
<Dialog.Title class="media-title">{kind || "Media"}</Dialog.Title>
<Dialog.Close class="media-close" aria-label="Close">
<Dialog.Title class="media-title">{title}</Dialog.Title>
<div class="media-actions">
{#if canSave}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="media-action"
aria-label="Скачать файл"
onclick={save}
>
<Icon name={saving ? "timer" : "download"} size="1.375rem" />
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/snippet}
</ContextMenu>
<button
type="button"
class="media-action"
aria-label="Доступ по ссылке"
onclick={share}
>
<Icon name="allow-share" size="1.375rem" />
</button>
{/if}
<Dialog.Close class="media-close" aria-label="Закрыть">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
{#if hasNav}
<span class="media-counter">{index + 1} / {items.length}</span>
{/if}
@@ -149,24 +237,38 @@
{:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<video class="media-video" src={result.url} controls></video>
<video
class="media-video"
src={result.url}
controls
playsinline
preload="auto"
use:poster
></video>
{:else if result?.state === "ready" && isAudio}
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<audio src={result.url} controls></audio>
{:else if result?.state === "ready"}
<a class="media-download" href={result.url} download>
<div class="media-file">
<span class="file-glyph"><Icon name="document" size="2rem" /></span>
<p class="file-name">{title}</p>
{#if fileSize}
<p class="file-size">{formatBytes(fileSize)}</p>
{/if}
<button class="media-download" type="button" onclick={save}>
<Icon name="download" />
Download file
</a>
Скачать файл
</button>
</div>
{:else if result?.state === "not-downloaded"}
<div class="media-message">
<p>This media has not been downloaded yet.</p>
<p>Файл ещё не скачан в архив.</p>
<Button variant="primary" fluid onclick={queueFetch}>
Fetch media
Скачать в архив
</Button>
</div>
{:else if result?.state === "missing"}
<p class="media-message">Media not found.</p>
<p class="media-message">Файл не найден.</p>
{/if}
</div>
{#if hasNav}
@@ -214,14 +316,18 @@
:global(.media-title) {
position: absolute;
top: 1rem;
top: 1.25rem;
left: 1.25rem;
overflow: hidden;
max-width: min(24rem, calc(100% - 14rem));
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
text-transform: capitalize;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-counter {
@@ -236,9 +342,6 @@
:global(.media-close) {
cursor: pointer;
position: absolute;
top: 0.75rem;
right: 1rem;
display: flex;
align-items: center;
@@ -274,13 +377,84 @@
text-align: center;
}
.media-file {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
max-width: min(24rem, 90vw);
color: var(--color-white);
text-align: center;
}
.file-glyph {
display: flex;
align-items: center;
justify-content: center;
width: 4.5rem;
height: 4.5rem;
margin-bottom: 0.5rem;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.12);
}
.file-name {
overflow-wrap: anywhere;
margin: 0;
font-size: 1rem;
}
.file-size {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.6);
}
.media-download {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: 0;
border-radius: 1.5rem;
font-family: inherit;
font-size: 0.9375rem;
color: var(--color-white);
text-decoration: none;
cursor: pointer;
background-color: rgba(255, 255, 255, 0.12);
}
.media-actions {
position: absolute;
top: 0.75rem;
right: 1rem;
display: flex;
align-items: center;
gap: 0.375rem;
z-index: 1;
}
.media-action {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: 50%;
color: var(--color-white);
background-color: rgba(255, 255, 255, 0.1);
}
.media-nav {
@@ -188,6 +188,7 @@
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
{own}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
+5 -13
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { tick } from "svelte";
import { tick, untrack } from "svelte";
import { listMessages } from "$lib/api/endpoints";
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
import type { LiveEvent, MessageView } from "$lib/api/types";
@@ -8,12 +8,11 @@
import MessageBubble from "$lib/components/MessageBubble.svelte";
import MessageVersions from "$lib/components/MessageVersions.svelte";
import PinnedBar from "$lib/components/PinnedBar.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
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";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { events } from "$lib/stores/events.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
@@ -425,14 +424,10 @@
});
$effect(() => {
const deps = {
account: accounts.selectedId,
revision: chats.revision,
};
if (deps.account === null) {
if (accounts.selectedId === null) {
return;
}
loadInitial();
untrack(() => loadInitial());
});
</script>
@@ -450,10 +445,7 @@
{/each}
</div>
{:else if rows.length === 0}
<EmptyState
title="No messages"
description="This chat has no archived messages"
/>
<TrackChat {chatId} />
{:else}
<div class="messages-container">
{#if loadingOlder}
+71 -12
View File
@@ -1,13 +1,16 @@
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
isPreviewable,
loadInlineMedia,
visualKind,
} from "$lib/api/media";
import type { MessageView } from "$lib/api/types";
import AudioFile from "$lib/components/media/AudioFile.svelte";
import FileChip from "$lib/components/media/FileChip.svelte";
import TgsSticker from "$lib/components/media/TgsSticker.svelte";
import VideoNote from "$lib/components/media/VideoNote.svelte";
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
@@ -15,6 +18,9 @@
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";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
@@ -32,9 +38,15 @@
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
let saving = $state(false);
const ref = $derived(message.media[0] ?? null);
const asFile = $derived(
ref !== null && ref.id !== null && !isPreviewable(ref.kind, ref.mime)
);
const ready = $derived(media?.state === "ready" ? media : null);
const kind = $derived(ready?.kind ?? "");
const kind = $derived(ready?.kind ?? ref?.kind ?? "");
const mime = $derived(ready?.mime ?? "");
const isImage = $derived(kind === "photo");
const isStaticSticker = $derived(
@@ -55,6 +67,10 @@
const label = $derived(
media && media.state !== "missing" ? media.kind : "media"
);
const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
);
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл");
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -63,6 +79,10 @@
}
async function start() {
if (asFile) {
loaded = true;
return;
}
media = await loadInlineMedia(message.chat_id, message.message_id);
loaded = true;
}
@@ -85,14 +105,34 @@
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Download queued");
toasts.success("Скачивание поставлено в очередь");
poll();
} catch {
toasts.error("Failed to queue download");
toasts.error("Не удалось поставить в очередь");
} finally {
queuing = false;
}
}
async function save() {
if (storedId === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(storedId);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (storedId !== null) {
shareUi.share({ kind: "media", mediaId: storedId }, fileName);
}
}
</script>
<ContextMenu>
@@ -101,8 +141,10 @@
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Self-destruct media</span>
<span>Самоуничтожающееся медиа</span>
</button>
{:else if asFile && ref}
<FileChip media={ref} {own} />
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"}
@@ -116,7 +158,7 @@
{:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} />
{:else if ready && kind === "audio"}
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
<AudioFile url={ready.url} title={fileName} {own} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment">
@@ -143,7 +185,13 @@
</button>
{:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} muted preload="metadata"></video>
<video
src={ready.url}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
</button>
{:else if ready}
@@ -154,32 +202,43 @@
{: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" ? "Video" : "Photo"}</span>
<small>{queuing ? "Queued" : "Tap to download"}</small>
<span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</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 ? "Queued" : `Download ${label}`}</span>
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Media</span>
<span>Медиа</span>
</button>
{/if}
</div>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть</ContextMenuItem
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
>
<ContextMenuItem icon="download" onselect={queue}>Скачать</ContextMenuItem>
{#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}>
Скачать в архив
</ContextMenuItem>
{:else}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
@@ -85,61 +85,6 @@
</Dialog.Root>
<style lang="scss">
:global(.dialog-overlay) {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background-color: rgba(0, 0, 0, 0.5);
}
:global(.dialog-content) {
position: fixed;
z-index: var(--z-modal);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
width: min(32rem, 92vw);
max-height: 80vh;
border-radius: var(--border-radius-default);
background-color: var(--color-background);
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
outline: none;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-borders);
}
:global(.dialog-title) {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
:global(.dialog-close) {
cursor: pointer;
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.versions {
overflow-y: auto;
padding: 0.75rem 1.25rem 1.25rem;
@@ -6,6 +6,7 @@
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
import ProfilePanel from "$lib/components/profile/ProfilePanel.svelte";
import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.svelte";
import SharesPanel from "$lib/components/shares/SharesPanel.svelte";
import CallbacksPanel from "$lib/components/social/CallbacksPanel.svelte";
import LinksPanel from "$lib/components/social/LinksPanel.svelte";
import ReactionsPanel from "$lib/components/social/ReactionsPanel.svelte";
@@ -31,6 +32,7 @@
policy: "Политика захвата",
watches: "Отслеживания",
alerts: "Алерты",
shares: "Файлы по ссылке",
};
</script>
@@ -74,6 +76,8 @@
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
<AnnotationsPanel />
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{/if}
</div>
@@ -0,0 +1,118 @@
<script lang="ts">
import { enqueueBackfill, trackChat } from "$lib/api/endpoints";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { discover } from "$lib/stores/discover.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
let busy = $state(false);
const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false);
const title = $derived(item?.title ?? "Этот чат");
$effect(() => {
if (accounts.selectedId === null) {
return;
}
discover.ensure(chatId).catch(() => undefined);
});
async function track() {
if (busy) {
return;
}
busy = true;
try {
discover.set(await trackChat(chatId, true));
toasts.success("Отслеживание включено, история загружается");
chats.refresh();
} catch {
toasts.error("Не удалось включить отслеживание");
} finally {
busy = false;
}
}
async function backfill() {
if (busy) {
return;
}
busy = true;
try {
await enqueueBackfill(chatId, true, true);
toasts.success("Полный бэкфилл запущен");
} catch {
toasts.error("Не удалось запустить бэкфилл");
} finally {
busy = false;
}
}
</script>
<div class="track">
<div class="title">Здесь пока нет сообщений</div>
<p class="description">
{#if tracked}
{title}
отслеживается — новые сообщения и статистика собираются автоматически.
{:else}
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
этому чату.
{/if}
</p>
<div class="actions">
{#if tracked}
<Button variant="secondary" pill loading={busy} onclick={backfill}>
<Icon name="cloud-download" />
<span>Загрузить историю</span>
</Button>
{:else}
<Button variant="primary" pill loading={busy} onclick={track}>
<Icon name="stats" />
<span>Начать отслеживать</span>
</Button>
{/if}
</div>
</div>
<style lang="scss">
.track {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 80%;
padding: 0 1.5rem;
}
.title {
margin-bottom: 0.25rem;
font-size: 1.25rem;
text-align: center;
}
.description {
max-width: 22rem;
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: center;
}
.actions {
display: flex;
gap: 0.5rem;
}
</style>
@@ -3,6 +3,7 @@
import type { JobStatus, JobView } from "$lib/api/types";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
@@ -15,6 +16,7 @@
const KIND_LABELS: Record<string, string> = {
backfill: "Бэкфилл",
backfill_stories: "Бэкфилл сторис",
fetch_media: "Докачка медиа",
fetch_avatar: "Аватар",
fetch_custom_emoji: "Кастом-эмодзи",
@@ -78,22 +80,26 @@
schedule();
}
function kindLabel(kind: string): string {
return KIND_LABELS[kind] ?? kind;
function kindLabel(job: JobView): string {
const label = KIND_LABELS[job.kind] ?? job.kind;
if (job.kind !== "backfill") {
return label;
}
return job.params.full ? `${label} (полный)` : `${label} (новые)`;
}
function processed(job: JobView): number | null {
const value = job.progress.processed;
const value = job.progress.processed ?? job.progress.saved;
return typeof value === "number" ? value : null;
}
function chatId(job: JobView): number | null {
const value = job.params.chat_id;
const value = job.params.chat_id ?? job.params.peer_id;
return typeof value === "number" ? value : null;
}
$effect(() => {
if (version >= 0) {
if (version >= 0 && accounts.selectedId !== null) {
load().catch(() => {
loading = false;
});
@@ -116,7 +122,7 @@
{#each jobs as job (job.id)}
<div class="job">
<div class="job-head">
<span class="kind">{kindLabel(job.kind)}</span>
<span class="kind">{kindLabel(job)}</span>
{#if canCancel(job)}
<button
type="button"
@@ -1,10 +1,15 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import { enqueueBackfill, syncDialogs } from "$lib/api/endpoints";
import {
enqueueBackfill,
syncContacts,
syncDialogs,
} from "$lib/api/endpoints";
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 { createChatPicker } from "$lib/stores/chat-picker.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
@@ -17,14 +22,20 @@
let filter = $state("");
let starting = $state(false);
let syncing = $state(false);
let syncingContacts = $state(false);
const availableChats = $derived(
chats.list
.filter((c) =>
(c.title ?? "").toLowerCase().includes(filter.trim().toLowerCase())
)
.slice(0, 40)
);
const picker = createChatPicker();
const availableChats = $derived(picker.results);
$effect(() => {
picker.search(filter);
});
$effect(() => {
if (selected !== null) {
chats.ensure(selected);
}
});
function chatTitle(id: number | null): string {
if (id === null) {
@@ -45,8 +56,8 @@
}
starting = true;
try {
await enqueueBackfill(selected, media);
toasts.success("Бэкфилл запущен");
await enqueueBackfill(selected, media, true);
toasts.success("Полный бэкфилл запущен");
version += 1;
} catch {
toasts.error("Не удалось запустить бэкфилл");
@@ -71,6 +82,22 @@
}
}
async function syncContactList() {
if (syncingContacts) {
return;
}
syncingContacts = true;
try {
await syncContacts();
toasts.success("Синхронизация контактов запущена");
version += 1;
} catch {
toasts.error("Не удалось синхронизировать контакты");
} finally {
syncingContacts = false;
}
}
onMount(() => {
chats.load();
});
@@ -89,10 +116,29 @@
<span>Синхронизировать диалоги</span>
</Button>
</div>
<p class="hint">
Контакты подтягиваются отдельно — тогда в поиске появятся люди, с которыми
ещё не было переписки.
</p>
<div class="action">
<Button
variant="secondary"
fluid
loading={syncingContacts}
onclick={syncContactList}
>
<Icon name="user" />
<span>Синхронизировать контакты</span>
</Button>
</div>
</section>
<section>
<div class="section-title">Бэкфилл</div>
<p class="hint">
Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в
шапке чата догружает только сообщения новее последнего сохранённого.
</p>
<button
type="button"
@@ -150,7 +196,7 @@
onclick={start}
>
<Icon name="cloud-download" />
<span>Запустить бэкфилл</span>
<span>Запустить полный бэкфилл</span>
</Button>
</div>
</section>
@@ -5,6 +5,7 @@
import type { MediaRef } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { poster } from "$lib/media/poster";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
@@ -46,7 +47,13 @@
<div class="AlbumTile" use:visible={start}>
{#if ready && isVideo}
<button class="tile" onclick={onopen} type="button">
<video src={ready.url} muted preload="metadata"></video>
<video
src={ready.url}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="large-play" size="2rem" /></span>
</button>
{:else if ready}
@@ -0,0 +1,179 @@
<script lang="ts">
import { downloadMedia } from "$lib/api/download";
import type { MediaRef } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
media: MediaRef;
own?: boolean;
}
const { media, own = false }: Props = $props();
let saving = $state(false);
const name = $derived(
media.file_name ?? mediaKindLabel(media.kind) ?? "Файл"
);
const size = $derived(formatBytes(media.file_size));
async function save() {
if (media.id === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(media.id);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (media.id !== null) {
shareUi.share({ kind: "media", mediaId: media.id }, name);
}
}
</script>
<div class="FileChip" class:own>
<button class="file-main" type="button" title={name} onclick={save}>
<span class="file-glyph" class:own>
<Icon name={saving ? "timer" : "document"} size="1.25rem" />
</span>
<span class="file-text">
<span class="file-name">{name}</span>
<span class="file-sub">{size || mediaKindLabel(media.kind)}</span>
</span>
</button>
<button
class="file-share"
type="button"
aria-label="Доступ по ссылке"
onclick={share}
>
<Icon name="allow-share" size="1.125rem" />
</button>
</div>
<style lang="scss">
.FileChip {
display: flex;
align-items: center;
gap: 0.25rem;
width: 100%;
max-width: 20rem;
padding: 0.25rem 0.375rem 0.25rem 0.25rem;
border-radius: var(--border-radius-default-small);
background-color: var(--color-primary-tint);
&.own {
background-color: var(--color-code-own-bg);
}
}
.file-main {
cursor: pointer;
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0.25rem;
border: 0;
font-family: inherit;
text-align: start;
background: transparent;
}
.file-glyph :global(.icon) {
transform: translate(0.5px, -0.5px);
}
.file-glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
&.own {
color: var(--color-own-links);
background-color: color-mix(
in srgb,
var(--color-own-links) 20%,
transparent
);
}
}
.file-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.file-name {
overflow: hidden;
font-size: 0.9375rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.file-sub {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.own .file-sub {
color: var(--color-message-meta-own);
}
.file-share {
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 50%;
color: var(--color-primary);
background: transparent;
&:hover {
background-color: var(--color-primary-opacity);
}
}
.own .file-share {
color: var(--color-own-links);
&:hover {
background-color: color-mix(in srgb, currentcolor 18%, transparent);
}
}
</style>
@@ -2,6 +2,7 @@
import Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
import { POSTER_TIME, poster } from "$lib/media/poster";
interface Props {
transcript?: string | null;
@@ -52,12 +53,13 @@
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
playsinline
preload="metadata"
preload="auto"
src={url}
use:poster
></video>
<svg class="ring" viewBox="0 0 200 200" aria-hidden="true">
<svg class="RoundVideoRing" viewBox="0 0 200 200" aria-hidden="true">
<circle
class="ring-progress"
class="RoundVideoProgress"
cx="100"
cy="100"
r={RADIUS}
@@ -70,7 +72,7 @@
{/if}
<span class="badge">
<Icon name="microphone" size="0.875rem" />
{formatDuration(paused && currentTime === 0 ? duration : remaining)}
{formatDuration(paused && currentTime <= POSTER_TIME ? duration : remaining)}
</span>
</button>
{#if transcript}
@@ -137,20 +139,25 @@
height: 13rem;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
-webkit-tap-highlight-color: transparent;
}
video {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
background-color: var(--color-default-shadow);
clip-path: circle(50%);
}
.ring {
.RoundVideoRing {
pointer-events: none;
position: absolute;
inset: 0;
@@ -160,7 +167,7 @@
height: 100%;
}
.ring-progress {
.RoundVideoProgress {
fill: transparent;
stroke: var(--color-white);
stroke-width: 4;
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
@@ -16,6 +15,8 @@
import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { createChatPicker } from "$lib/stores/chat-picker.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
@@ -79,15 +80,25 @@
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
)
);
const picker = createChatPicker();
const availableChats = $derived(
chats.list
.filter((c) => !chatPolicies.some((p) => p.scope_id === c.chat_id))
.filter((c) =>
(c.title ?? "").toLowerCase().includes(chatFilter.trim().toLowerCase())
picker.results.filter(
(c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)
)
.slice(0, 40)
);
$effect(() => {
picker.search(chatFilter);
});
$effect(() => {
for (const policy of chatPolicies) {
if (policy.scope_id !== null) {
chats.ensure(policy.scope_id);
}
}
});
function folderTitle(id: number | null): string {
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
}
@@ -110,7 +121,8 @@
p.id === record.id ? { ...p, [key]: value } : p
);
try {
await updatePolicy(record.id, next);
const saved = await updatePolicy(record.id, next);
policies = policies.map((p) => (p.id === record.id ? saved : p));
} catch {
toasts.error("Не удалось сохранить политику");
await reload();
@@ -143,7 +155,8 @@
}
}
onMount(async () => {
async function load(_account: number | null) {
loading = true;
chats.load();
try {
await reload();
@@ -152,6 +165,10 @@
} finally {
loading = false;
}
}
$effect(() => {
load(accounts.selectedId);
});
</script>
@@ -159,7 +176,10 @@
<div class="card">
<div class="card-head">
<span class="card-title">{title}</span>
{#if onremove}
{#if record.account_id === null}
<span class="shared">для всех аккаунтов</span>
{/if}
{#if onremove && record.account_id !== null}
<button
type="button"
class="remove"
@@ -303,6 +323,11 @@
font-weight: var(--font-weight-medium);
}
.shared {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.remove {
cursor: pointer;
display: flex;
@@ -6,19 +6,21 @@
import ProfileInfo from "$lib/components/profile/ProfileInfo.svelte";
import SharedLinks from "$lib/components/profile/SharedLinks.svelte";
import SharedMedia from "$lib/components/profile/SharedMedia.svelte";
import StoriesArchive from "$lib/components/stories/StoriesArchive.svelte";
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";
import { chats } from "$lib/stores/chats.svelte";
type Tab = "info" | "media" | "files" | "links" | "calendar";
type Tab = "info" | "media" | "files" | "links" | "stories" | "calendar";
const TABS: { id: Tab; icon: string; label: string }[] = [
{ id: "info", icon: "info", label: "Инфо" },
{ id: "media", icon: "photo", label: "Медиа" },
{ id: "files", icon: "document", label: "Файлы" },
{ id: "links", icon: "link", label: "Ссылки" },
{ id: "stories", icon: "play-story", label: "Сторис" },
{ id: "calendar", icon: "calendar", label: "Календарь" },
];
@@ -114,6 +116,8 @@
<SharedMedia {chatId} kinds={FILE_KINDS} layout="list" />
{:else if tab === "links"}
<SharedLinks {chatId} />
{:else if tab === "stories"}
<StoriesArchive {chatId} />
{:else if tab === "calendar"}
<ChatCalendar {chatId} />
{/if}
@@ -1,16 +1,29 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { downloadMedia } from "$lib/api/download";
import { getChatMedia } from "$lib/api/endpoints";
import { type InlineMedia, loadMediaItem, visualKind } from "$lib/api/media";
import {
type InlineMedia,
loadMediaItem,
type ViewerItem,
visualKind,
} from "$lib/api/media";
import type { MediaView } from "$lib/api/types";
import MediaViewer from "$lib/components/MediaViewer.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.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 { formatListDate } from "$lib/format/datetime";
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { isMobile } from "$lib/viewport";
interface Props {
chatId: number;
@@ -25,9 +38,39 @@
let items = $state<MediaView[]>([]);
let loading = $state(false);
let done = $state(false);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const previews = $state<Record<number, InlineMedia>>({});
let token = 0;
const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
fileName: item.file_name,
fileSize: item.file_size,
mime: item.mime,
}))
);
function displayName(item: MediaView): string {
return item.file_name ?? mediaKindLabel(item.kind) ?? "Файл";
}
async function save(item: MediaView) {
try {
await downloadMedia(item.id);
} catch {
toasts.error("Не удалось скачать файл");
}
}
function share(item: MediaView) {
shareUi.share({ kind: "media", mediaId: item.id }, displayName(item));
}
async function loadMore() {
if (loading || done) {
return;
@@ -92,6 +135,7 @@
kind: item.kind,
downloaded: item.downloaded,
mime: item.mime,
file_name: item.file_name,
file_size: item.file_size,
ttl_seconds: item.ttl_seconds,
duration: null,
@@ -109,9 +153,17 @@
};
});
function open(messageId: number) {
function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(messageId: number) {
ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`);
if (isMobile()) {
ui.closePanel();
}
}
function preview(item: MediaView): InlineMedia | undefined {
@@ -127,12 +179,29 @@
{/if}
{:else if layout === "grid"}
<div class="grid">
{#each items as item (item.id)}
<button type="button" class="tile" onclick={() => open(item.message_id)}>
{#each items as item, index (item.id)}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="tile"
tabindex="0"
onclick={() => open(index)}
>
{#if preview(item)?.state === "ready"}
{@const ready = preview(item) as Extract<InlineMedia, { state: "ready" }>}
{@const ready = preview(item) as Extract<
InlineMedia,
{ state: "ready" }
>}
{#if visualKind(item.kind) === "video"}
<video src={ready.url} muted preload="metadata"></video>
<video
src={ready.url}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={ready.url} alt="">
@@ -143,16 +212,41 @@
>
{/if}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>
{/if}
{/snippet}
</ContextMenu>
{/each}
</div>
{:else}
<ul class="list">
{#each items as item (item.id)}
{#each items as item, index (item.id)}
<li>
<button type="button" onclick={() => open(item.message_id)}>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
tabindex="0"
onclick={() => open(index)}
>
<span class="file-icon"><Icon name="document" /></span>
<span class="meta">
<span class="name">{mediaKindLabel(item.kind)}</span>
<span class="name">{displayName(item)}</span>
<span class="sub">
{formatListDate(item.created_at)}
{#if item.file_size}
@@ -161,6 +255,24 @@
</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>
{/if}
{/snippet}
</ContextMenu>
</li>
{/each}
</ul>
@@ -177,6 +289,13 @@
</button>
{/if}
<MediaViewer
bind:open={viewerOpen}
bind:index={viewerIndex}
{chatId}
items={viewerItems}
/>
<style lang="scss">
.center {
display: flex;
@@ -0,0 +1,122 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import type { DiscoverItem } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
interface Props {
item: DiscoverItem;
onclick: () => void;
}
let { item, onclick }: Props = $props();
const title = $derived(
item.title ?? (item.username ? `@${item.username}` : `Чат ${item.chat_id}`)
);
const avatarKind = $derived(item.chat_id > 0 ? "peer" : "chat");
const badge = $derived.by(() => {
if (item.is_bot) {
return "Бот";
}
if (item.kind === "channel") {
return "Канал";
}
if (item.kind === "group") {
return "Группа";
}
return item.is_contact ? "Контакт" : "Пользователь";
});
const status = $derived.by(() => {
if (item.message_count > 0) {
return `${item.message_count} сообщений`;
}
if (item.tracked) {
return "Отслеживается";
}
return "Нет в архиве";
});
</script>
<button type="button" class="Discover ListItem-button" use:ripple {onclick}>
<Avatar
name={title}
colorKey={item.chat_id}
avatar={{ kind: avatarKind, id: item.chat_id }}
hasAvatar={item.has_avatar}
/>
<div class="info">
<h3 class="title">{title}</h3>
<div class="subtitle">
<span class="badge">{badge}</span>
<span class="dot">·</span>
<span>{status}</span>
{#if item.username && item.title}
<span class="dot">·</span>
<span>@{item.username}</span>
{/if}
</div>
</div>
</button>
<style lang="scss">
.Discover {
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;
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);
}
}
}
.info {
overflow: hidden;
flex: 1;
}
.title {
overflow: hidden;
margin: 0;
font-weight: var(--font-weight-medium);
font-size: 1rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.subtitle {
display: flex;
gap: 0.25rem;
overflow: hidden;
font-size: 0.875rem;
color: var(--color-text-secondary);
white-space: nowrap;
text-overflow: ellipsis;
}
.badge {
color: var(--color-primary);
}
.dot {
opacity: 0.6;
}
</style>
@@ -19,6 +19,7 @@
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
$effect(() => {
chats.ensure(hit.chat_id);
const ids: number[] = [];
if (hit.chat_id > 0) {
ids.push(hit.chat_id);
@@ -2,6 +2,7 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
import ChatListItem from "$lib/components/ChatListItem.svelte";
import DiscoverResultItem from "$lib/components/search/DiscoverResultItem.svelte";
import SearchMessageItem from "$lib/components/search/SearchMessageItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
@@ -13,8 +14,11 @@
);
const hasChats = $derived(search.chatHits.length > 0);
const hasPeers = $derived(search.peerHits.length > 0);
const hasMessages = $derived(search.messageHits.length > 0);
const empty = $derived(!(search.loading || hasChats || hasMessages));
const empty = $derived(
!(search.loading || hasChats || hasPeers || hasMessages)
);
function openChat(chatId: number) {
search.close();
@@ -40,6 +44,13 @@
{/each}
{/if}
{#if hasPeers}
<div class="section-label">Контакты и каналы</div>
{#each search.peerHits as item (item.chat_id)}
<DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} />
{/each}
{/if}
{#if search.loading && !hasMessages}
<div class="loading"><Spinner /></div>
{:else if hasMessages}
@@ -0,0 +1,258 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { ApiError } from "$lib/api/client";
import {
cancelLogin,
pollQrLogin,
startLogin,
startQrLogin,
submitLoginCode,
submitLoginPassword,
} from "$lib/api/endpoints";
import type { LoginState } from "$lib/api/types";
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 { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
type Step = "qr" | "phone" | "code" | "password";
let { open = $bindable(false) }: { open?: boolean } = $props();
let step = $state<Step>("qr");
let loginId = $state("");
let qrUrl = $state("");
let phone = $state("");
let code = $state("");
let password = $state("");
let busy = $state(false);
let attempt = 0;
const hints: Record<Step, string> = {
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
phone: "Номер телефона в международном формате, например +79991234567.",
code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.",
};
const filled = $derived.by(() => {
if (step === "phone") {
return phone.trim().length > 0;
}
if (step === "code") {
return code.trim().length > 0;
}
return password.length > 0;
});
async function apply(state: LoginState) {
if (state.stage !== "done") {
loginId = state.login_id;
step = state.stage;
return;
}
loginId = "";
await accounts.load();
if (state.account) {
accounts.select(state.account.account_id);
}
toasts.success("Аккаунт добавлен");
open = false;
}
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
}
function drop() {
attempt += 1;
if (loginId) {
cancelLogin(loginId).catch(() => undefined);
loginId = "";
}
}
async function watchQr() {
const mine = attempt;
try {
let state = await startQrLogin();
while (mine === attempt && state.stage === "qr") {
loginId = state.login_id;
qrUrl = state.qr_url ?? "";
state = await pollQrLogin(state.login_id);
}
if (mine === attempt) {
await apply(state);
}
} catch (error) {
if (mine === attempt) {
fail(error, "Не удалось получить QR-код");
}
}
}
function useQr() {
drop();
step = "qr";
qrUrl = "";
watchQr();
}
function usePhone() {
drop();
step = "phone";
qrUrl = "";
}
function begin() {
attempt += 1;
loginId = "";
phone = "";
code = "";
password = "";
useQr();
}
function next(): Promise<LoginState> {
if (step === "phone") {
return startLogin(phone.trim());
}
if (step === "code") {
return submitLoginCode(loginId, code.trim());
}
return submitLoginPassword(loginId, password);
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || !filled) {
return;
}
busy = true;
try {
await apply(await next());
} catch (error) {
fail(error, "Не удалось войти");
} finally {
busy = false;
}
}
function onOpenChange(value: boolean) {
if (!value) {
drop();
}
}
$effect(() => {
if (open) {
untrack(begin);
}
});
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
{#if step === "qr"}
<div class="dialog-body">
<p class="hint">{hints.qr}</p>
<div class="qr-slot">
{#if qrUrl}
<QrCode value={qrUrl} label="QR-код для входа в Telegram" />
{/if}
</div>
</div>
<div class="dialog-actions">
<Button variant="text" pill onclick={usePhone}>
Войти по номеру телефона
</Button>
</div>
{:else}
<form onsubmit={submit}>
<div class="dialog-body">
<p class="hint">{hints[step]}</p>
{#if step === "phone"}
<div class="input-group">
<input
id="login-phone"
class="form-control"
type="tel"
autocomplete="tel"
placeholder="+7 999 123-45-67"
bind:value={phone}
>
<label for="login-phone">Номер телефона</label>
</div>
{:else if step === "code"}
<div class="input-group">
<input
id="login-code"
class="form-control"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
bind:value={code}
>
<label for="login-code">Код подтверждения</label>
</div>
{:else}
<div class="input-group">
<input
id="login-password"
class="form-control"
type="password"
autocomplete="current-password"
placeholder="Пароль"
bind:value={password}
>
<label for="login-password">Облачный пароль</label>
</div>
{/if}
</div>
<div class="dialog-actions">
{#if step === "phone"}
<Button variant="text" pill onclick={useQr}>QR-код</Button>
{/if}
<Button
type="submit"
pill
loading={busy}
disabled={busy || !filled}
>
{step === "phone" ? "Отправить код" : "Продолжить"}
</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.hint {
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.qr-slot {
display: grid;
place-items: center;
width: min(15rem, 100%);
aspect-ratio: 1;
margin: 0 auto;
background: var(--color-background-secondary);
border-radius: 0.75rem;
}
</style>
@@ -1,9 +1,15 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { ripple } from "$lib/actions/ripple";
import { ApiError } from "$lib/api/client";
import AddAccountDialog from "$lib/components/settings/AddAccountDialog.svelte";
import SettingsItem from "$lib/components/settings/SettingsItem.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
const current = $derived(accounts.selected);
const others = $derived(
@@ -11,6 +17,58 @@
(account) => account.account_id !== accounts.selectedId
)
);
const DEVICE_LIMIT = 32;
const DEFAULT_DEVICE = "Beavergram";
let adding = $state(false);
let confirming = $state(false);
let renaming = $state(false);
let deviceName = $state("");
let busy = $state(false);
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
function openRename() {
deviceName = deviceLabel;
renaming = true;
}
async function rename(event: SubmitEvent) {
event.preventDefault();
const value = deviceName.trim();
if (!(current && value) || busy) {
return;
}
busy = true;
try {
await accounts.renameDevice(current.account_id, value);
renaming = false;
toasts.success("Имя устройства обновлено");
} catch (error) {
toasts.error(
error instanceof ApiError ? error.detail : "Не удалось переименовать"
);
} finally {
busy = false;
}
}
async function logout() {
if (!current || busy) {
return;
}
busy = true;
try {
await accounts.logout(current.account_id);
confirming = false;
toasts.success("Сессия завершена");
} catch {
toasts.error("Не удалось выйти из аккаунта");
} finally {
busy = false;
}
}
</script>
<div class="my-account">
@@ -25,6 +83,9 @@
{#if current.phone}
<div class="phone">+{current.phone}</div>
{/if}
{#if !current.is_active}
<div class="inactive">Сессия завершена, доступен только архив</div>
{/if}
</div>
{/if}
@@ -43,12 +104,123 @@
size={2.25}
/>
<span>{accountName(account)}</span>
{#if !account.is_active}
<span class="tag">архив</span>
{/if}
<Icon name="arrow-right" size="1rem" class="chevron" />
</button>
{/each}
</div>
{/if}
<div class="actions">
{#if current?.is_active}
<SettingsItem
icon="active-sessions"
label="Имя устройства"
value={deviceLabel}
onclick={openRename}
/>
{/if}
<SettingsItem
icon="add-user"
label="Добавить аккаунт"
onclick={() => {
adding = true;
}}
/>
{#if current?.is_active}
<SettingsItem
icon="logout"
label="Выйти из аккаунта"
onclick={() => {
confirming = true;
}}
/>
{/if}
</div>
</div>
<AddAccountDialog bind:open={adding} />
<Dialog.Root bind:open={renaming}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<form onsubmit={rename}>
<div class="dialog-body">
<p class="confirm-text">
Так сессия называется в списке устройств Telegram. Чтобы имя
применилось, соединение переподключится — сбор сообщений прервётся
на пару секунд.
</p>
<div class="input-group">
<input
id="device-name"
class="form-control"
type="text"
maxlength={DEVICE_LIMIT}
placeholder={DEFAULT_DEVICE}
bind:value={deviceName}
>
<label for="device-name">Название</label>
</div>
</div>
<div class="dialog-actions">
<Button
type="submit"
pill
loading={busy}
disabled={busy || deviceName.trim().length === 0}
>
Сохранить
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root bind:open={confirming}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
<p class="confirm-text">
Сессия {current ? accountName(current) : ""} будет завершена в
Telegram, новые сообщения перестанут собираться. Уже собранный архив
останется доступным.
</p>
</div>
<div class="dialog-actions">
<Button
variant="text"
pill
onclick={() => {
confirming = false;
}}
>
Отмена
</Button>
<Button variant="danger" pill loading={busy} onclick={logout}>
Выйти
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.profile {
@@ -71,6 +243,11 @@
color: var(--color-text-secondary);
}
.inactive {
font-size: 0.8125rem;
color: var(--color-error);
}
.switch-row {
cursor: pointer;
position: relative;
@@ -99,6 +276,22 @@
}
}
.tag {
flex: 0 0 auto !important;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.actions {
padding: 0.5rem 0;
border-top: 1px solid var(--color-borders);
}
.confirm-text {
margin: 0;
color: var(--color-text-secondary);
}
:global(.switch-row .chevron) {
flex-shrink: 0;
color: var(--color-icon-secondary);
@@ -52,6 +52,11 @@
label="Сторис"
onclick={() => ui.openPanel("stories-all")}
/>
<SettingsItem
icon="allow-share"
label="Файлы по ссылке"
onclick={() => ui.openPanel("shares")}
/>
<SettingsItem
icon="eye"
label="Отслеживания"
@@ -0,0 +1,471 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { ApiError } from "$lib/api/client";
import {
createShare,
lookupShare,
revokeShare,
type ShareSettings,
shareUrl,
updateShare,
} from "$lib/api/shares";
import type { FileShare, ShareSubject } from "$lib/api/types";
import ShareLinkField from "$lib/components/shares/ShareLinkField.svelte";
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 Spinner from "$lib/components/ui/Spinner.svelte";
import { formatShareLimits } from "$lib/format/shares";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
label?: string;
onchange?: (share: FileShare | null) => void;
open: boolean;
subject: ShareSubject | null;
}
let {
open = $bindable(),
subject,
label = "файл",
onchange,
}: Props = $props();
const HOUR = 3600;
const DAY = 24 * HOUR;
const KEEP = -1;
const expiryChoices = [
{ label: "Бессрочно", value: null },
{ label: "1 час", value: HOUR },
{ label: "24 часа", value: DAY },
{ label: "7 дней", value: 7 * DAY },
{ label: "30 дней", value: 30 * DAY },
];
const keepChoice = { label: "Как есть", value: KEEP };
const limitChoices = [
{ label: "Без лимита", value: null },
{ label: "1", value: 1 },
{ label: "5", value: 5 },
{ label: "25", value: 25 },
{ label: "100", value: 100 },
];
let share = $state<FileShare | null>(null);
let loading = $state(false);
let busy = $state(false);
let expiresIn = $state<number | null>(null);
let maxDownloads = $state<number | null>(null);
let showQr = $state(false);
let token = 0;
const settings = $derived<ShareSettings>({
expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
keepExpiry: expiresIn === KEEP,
maxDownloads,
});
const liveExpiryChoices = $derived(
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
);
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
}
function adopt(next: FileShare | null) {
share = next;
onchange?.(next);
}
async function load(target: ShareSubject) {
loading = true;
share = null;
showQr = false;
expiresIn = null;
maxDownloads = null;
const current = ++token;
try {
const found = await lookupShare(target);
if (current === token) {
share = found;
maxDownloads = found?.max_downloads ?? null;
expiresIn = found?.expires_at ? KEEP : null;
}
} catch (error) {
if (current === token) {
fail(error, "Не удалось проверить ссылку");
open = false;
}
} finally {
if (current === token) {
loading = false;
}
}
}
async function publish() {
if (!subject || busy) {
return;
}
busy = true;
try {
adopt(await createShare(subject, settings));
toasts.success("Доступ по ссылке открыт");
} catch (error) {
fail(error, "Не удалось открыть доступ");
} finally {
busy = false;
}
}
async function applyLimits() {
if (!share || busy) {
return;
}
busy = true;
try {
adopt(await updateShare(share.id, settings));
toasts.success("Настройки ссылки обновлены");
} catch (error) {
fail(error, "Не удалось обновить ссылку");
} finally {
busy = false;
}
}
async function revoke() {
if (!share || busy) {
return;
}
busy = true;
try {
await revokeShare(share.id);
adopt(null);
toasts.success("Доступ отозван");
open = false;
} catch (error) {
fail(error, "Не удалось отозвать доступ");
} finally {
busy = false;
}
}
$effect(() => {
const target = subject;
const isOpen = open;
untrack(() => {
if (isOpen && target) {
load(target);
}
});
});
</script>
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if loading}
<div class="center"><Spinner /></div>
{:else if share}
<div class="subject live">
<span class="badge"
><Icon name="allow-share" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{share.file_name}</strong>
<span>{formatShareLimits(share)}</span>
</div>
</div>
<ShareLinkField url={shareUrl(share)} />
<div class="qr-row">
<button
type="button"
class="qr-toggle"
onclick={() => {
showQr = !showQr;
}}
>
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
</button>
</div>
{#if showQr}
<div class="qr-slot">
<QrCode value={shareUrl(share)} label="QR-код ссылки на файл" />
</div>
{/if}
<p class="hint raw">
Ссылка отдаёт файл как есть: картинки и видео откроются прямо в
браузере, <code>curl -O {shareUrl(share)}</code> скачает под
настоящим именем. Превью-краулеры Telegram скачивания не списывают.
</p>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => {
maxDownloads = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<div class="segments">
{#each liveExpiryChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={expiresIn === choice.value}
onclick={() => {
expiresIn = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
{:else}
<div class="subject">
<span class="badge muted"
><Icon name="lock" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{label}</strong>
<span>Сейчас доступен только вам</span>
</div>
</div>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<div class="segments">
{#each expiryChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={expiresIn === choice.value}
onclick={() => {
expiresIn = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => {
maxDownloads = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<p class="hint">
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
момент.
</p>
{/if}
</div>
{#if !loading}
<div class="dialog-actions">
{#if share}
<Button variant="danger" pill loading={busy} onclick={revoke}>
Отозвать
</Button>
<Button pill loading={busy} onclick={applyLimits}>Сохранить</Button>
{:else}
<Button pill fluid loading={busy} onclick={publish}>
Открыть доступ
</Button>
{/if}
</div>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.subject {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.badge {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-green);
&.muted {
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
}
.subject-text {
display: flex;
flex-direction: column;
min-width: 0;
strong {
overflow: hidden;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
span {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.qr-row {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.qr-toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 0;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-primary);
cursor: pointer;
background: transparent;
}
.qr-slot {
--qr-bg: transparent;
--qr-fg: var(--color-text);
width: min(11rem, 60%);
margin: 0.25rem auto 0;
}
.hint {
margin: 1rem 0 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-secondary);
&.raw {
margin-top: 0.875rem;
}
code {
overflow-wrap: anywhere;
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
background-color: var(--color-background-secondary);
}
}
.choices {
margin: 1.25rem 0 0;
padding: 0;
border: 0;
}
legend {
padding: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.segments {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.segment {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text);
cursor: pointer;
background-color: transparent;
transition:
background-color 0.15s,
border-color 0.15s,
color 0.15s;
&.selected {
border-color: transparent;
color: var(--color-white);
background-color: var(--color-primary);
}
}
</style>
@@ -0,0 +1,81 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
url: string;
}
const { url }: Props = $props();
const COPIED_MS = 1600;
let copied = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
async function copy() {
try {
await navigator.clipboard.writeText(url);
} catch {
toasts.error("Не удалось скопировать ссылку");
return;
}
copied = true;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
copied = false;
}, COPIED_MS);
}
</script>
<button type="button" class="link-field" class:copied onclick={copy}>
<span class="url">{url}</span>
<span class="action">
<Icon name={copied ? "check" : "copy"} size="1.125rem" />
</span>
</button>
<style lang="scss">
.link-field {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.625rem 0.625rem 0.875rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
font-family: inherit;
text-align: start;
cursor: pointer;
background-color: var(--color-background-secondary);
transition:
border-color 0.15s,
color 0.15s;
&.copied {
border-color: var(--color-green);
color: var(--color-green);
}
}
.url {
overflow-wrap: anywhere;
flex: 1;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.8125rem;
line-height: 1.35;
color: inherit;
}
.action {
display: flex;
flex-shrink: 0;
color: inherit;
}
</style>
@@ -0,0 +1,472 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
deleteShare,
listShares,
reissueShare,
revokeShare,
shareState,
shareUrl,
} from "$lib/api/shares";
import type { FileShare } from "$lib/api/types";
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 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";
import { formatBytes } from "$lib/format/media";
import { formatDownloads, formatExpiry, plural } from "$lib/format/shares";
import { accounts } from "$lib/stores/accounts.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
type Filter = "active" | "all";
const stateLabels: Record<string, string> = {
active: "Открыт",
revoked: "Отозван",
expired: "Истёк",
exhausted: "Лимит исчерпан",
};
let items = $state<FileShare[]>([]);
let loading = $state(false);
let filter = $state<Filter>("active");
let expanded = $state<number | null>(null);
let token = 0;
const visible = $derived(
filter === "active"
? items.filter((item) => shareState(item) === "active")
: items
);
const activeCount = $derived(
items.filter((item) => shareState(item) === "active").length
);
async function load() {
const current = ++token;
loading = true;
try {
const rows = await listShares();
if (current === token) {
items = rows;
}
} catch {
if (current === token) {
toasts.error("Не удалось загрузить список ссылок");
}
} finally {
if (current === token) {
loading = false;
}
}
}
function replace(next: FileShare) {
items = items.map((item) => (item.id === next.id ? next : item));
}
function jump(share: FileShare) {
if (share.chat_id === null || share.message_id === null) {
if (share.peer_id !== null) {
goto(`/app/${share.peer_id}`);
ui.openPanel("stories");
}
return;
}
goto(`/app/${share.chat_id}`);
ui.requestJump(share.chat_id, share.message_id);
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("Доступ отозван");
} catch {
toasts.error("Не удалось отозвать доступ");
}
}
async function reissue(share: FileShare) {
try {
replace(await reissueShare(share.id));
toasts.success("Выдана новая ссылка");
} catch {
toasts.error("Не удалось перевыпустить ссылку");
}
}
async function forget(share: FileShare) {
try {
await deleteShare(share.id);
items = items.filter((item) => item.id !== share.id);
} catch {
toasts.error("Не удалось удалить запись");
}
}
let loadedKey = "";
$effect(() => {
const key = `${accounts.selectedId}:${shareUi.revision}`;
if (accounts.selectedId !== null && key !== loadedKey) {
loadedKey = key;
load();
}
});
</script>
<div class="toolbar">
<div class="tabs">
<button
type="button"
class="tab"
class:selected={filter === "active"}
onclick={() => {
filter = "active";
}}
>
Открытые{activeCount ? ` · ${activeCount}` : ""}
</button>
<button
type="button"
class="tab"
class:selected={filter === "all"}
onclick={() => {
filter = "all";
}}
>
Все
</button>
</div>
<button
type="button"
class="reload"
aria-label="Обновить"
onclick={() => load()}
>
<Icon name="reload" size="1.125rem" />
</button>
</div>
{#if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if visible.length === 0}
<EmptyState
title="Ничего не открыто"
description="Откройте доступ к файлу из меню медиа"
/>
{:else}
<ul class="list">
{#each visible as share (share.id)}
{@const state = shareState(share)}
<li class="row" class:inactive={state !== "active"}>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="head"
onclick={() => {
expanded = expanded === share.id ? null : share.id;
}}
>
<span class="glyph" class:muted={state !== "active"}>
<Icon
name={state === "active" ? "allow-share" : "no-share"}
size="1.125rem"
/>
</span>
<span class="text">
<span class="name">{share.file_name}</span>
<span class="sub">
{stateLabels[state]}
· {formatDownloads(share)}
{#if share.file_size}
· {formatBytes(share.file_size)}
{/if}
</span>
<span class="sub">
{share.title ?? "Без чата"}
· {formatExpiry(share)}
</span>
</span>
<span class="chevron" class:open={expanded === share.id}>
<Icon name="down" size="1rem" />
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}>
Копировать ссылку
</ContextMenuItem>
{#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}>
Перейти к сообщению
</ContextMenuItem>
{/if}
{#if state === "active"}
<ContextMenuItem
icon="link-broken"
onselect={() => revoke(share)}
>
Отозвать
</ContextMenuItem>
{:else}
<ContextMenuItem icon="replace" onselect={() => reissue(share)}>
Выдать новую ссылку
</ContextMenuItem>
<ContextMenuItem icon="delete" onselect={() => forget(share)}>
Убрать из списка
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
{#if expanded === share.id}
<div class="details">
<ShareLinkField url={shareUrl(share)} />
{#if share.last_download_at}
<p class="last">
Последнее скачивание: {formatFull(share.last_download_at)}
</p>
{/if}
<div class="actions">
{#if share.message_id !== null || share.peer_id !== null}
<button type="button" class="pill" onclick={() => jump(share)}>
<Icon name="reply" size="1rem" />К сообщению
</button>
{/if}
{#if state === "active"}
<button
type="button"
class="pill danger"
onclick={() => revoke(share)}
>
<Icon name="link-broken" size="1rem" />Отозвать
</button>
{:else}
<button
type="button"
class="pill"
onclick={() => reissue(share)}
>
<Icon name="replace" size="1rem" />Новая ссылка
</button>
{/if}
</div>
</div>
{/if}
</li>
{/each}
</ul>
<p class="footnote">
{visible.length}
{plural(visible.length, "ссылка", "ссылки", "ссылок")}
· превью-краулеры не списывают скачивания
</p>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.tabs {
display: flex;
gap: 0.375rem;
}
.tab {
padding: 0.3125rem 0.75rem;
border: 0;
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-background-secondary);
&.selected {
color: var(--color-white);
background-color: var(--color-primary);
}
}
.reload {
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.row {
border-radius: 0.75rem;
&.inactive .name {
color: var(--color-text-secondary);
text-decoration: line-through;
}
}
.head {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: 0.75rem;
font-family: inherit;
text-align: start;
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-green);
&.muted {
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
overflow: hidden;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: flex;
flex-shrink: 0;
color: var(--color-text-secondary);
transition: transform 0.15s;
&.open {
transform: rotate(180deg);
}
}
.details {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 0.5rem 0.75rem;
}
.last {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.6875rem;
border: 1px solid var(--color-borders);
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text);
cursor: pointer;
background: transparent;
&.danger {
color: var(--color-error);
}
}
.footnote {
margin: 0;
padding: 0 1rem 1rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,16 +1,24 @@
<script lang="ts">
import { untrack } from "svelte";
import { getPeers, getStories } from "$lib/api/endpoints";
import {
enqueueStoriesBackfill,
getChat,
getPeers,
getStories,
} from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.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 Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { peerName } from "$lib/format/peer";
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Group {
hasAvatar: boolean;
@@ -25,18 +33,20 @@
let groups = $state<Group[]>([]);
let loading = $state(false);
const previews = $state<Record<number, string | null>>({});
const expanded = $state<Record<number, boolean>>({});
let token = 0;
let viewerOpen = $state(false);
let viewerIndex = $state(0);
let viewerItems = $state<StoryView[]>([]);
let viewerPeerId = $state(0);
let backfilling = $state<number | null>(null);
async function load() {
const current = token;
loading = true;
try {
const stories = await getStories(0, { limit: FETCH_LIMIT });
const stories = await getStories(null, { limit: FETCH_LIMIT });
if (current !== token) {
return;
}
@@ -50,11 +60,20 @@
}
}
const peerIds = [...byPeer.keys()].filter((id) => id > 0);
const peers = await getPeers(peerIds);
const chatIds = [...byPeer.keys()].filter((id) => id < 0);
const [peers, fetched] = await Promise.all([
getPeers(peerIds),
Promise.all(chatIds.map((id) => getChat(id))),
]);
if (current !== token) {
return;
}
const peerById = new Map(peers.map((peer) => [peer.peer_id, peer]));
const chatById = new Map(
fetched
.filter((item) => item !== null)
.map((item) => [item.chat_id, item])
);
groups = [...byPeer.entries()].map(([peerId, stories]) => {
if (peerId > 0) {
const peer = peerById.get(peerId) ?? null;
@@ -66,7 +85,7 @@
stories,
};
}
const chat = chats.byId(peerId);
const chat = chatById.get(peerId);
return {
peerId,
kind: "chat" as const,
@@ -93,12 +112,17 @@
for (const key of Object.keys(previews)) {
delete previews[Number(key)];
}
for (const key of Object.keys(expanded)) {
delete expanded[Number(key)];
}
load().catch(() => undefined);
});
});
$effect(() => {
const list = groups.flatMap((group) => group.stories);
const list = groups
.filter((group) => expanded[group.peerId])
.flatMap((group) => group.stories);
let active = true;
untrack(() => {
for (const item of list) {
@@ -118,12 +142,31 @@
};
});
async function backfill(peerId: number) {
if (backfilling !== null) {
return;
}
backfilling = peerId;
try {
await enqueueStoriesBackfill(peerId);
toasts.success("Загружаем старые сторис");
} catch {
toasts.error("Не удалось запустить загрузку сторис");
} finally {
backfilling = null;
}
}
function openViewer(group: Group, index: number) {
viewerItems = group.stories;
viewerPeerId = group.peerId;
viewerIndex = index;
viewerOpen = true;
}
function toggle(peerId: number) {
expanded[peerId] = !expanded[peerId];
}
</script>
{#if groups.length === 0}
@@ -136,6 +179,12 @@
{#each groups as group (group.peerId)}
<section class="group">
<header class="group-head">
<button
type="button"
class="group-toggle"
aria-expanded={Boolean(expanded[group.peerId])}
onclick={() => toggle(group.peerId)}
>
<Avatar
name={group.name}
colorKey={group.peerId}
@@ -145,37 +194,32 @@
/>
<span class="group-name">{group.name}</span>
<span class="group-count">{group.stories.length}</span>
<span class="chevron" class:open={expanded[group.peerId]}>
<Icon name="down" size="1.25rem" />
</span>
</button>
<Button
variant="translucent"
round
smaller
loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)}
aria-label="Загрузить старые сторис"
>
<Icon name="cloud-download" />
</Button>
</header>
{#if expanded[group.peerId]}
<div class="grid">
{#each group.stories as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(group, index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
preload="metadata"
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
<StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(group, index)}
/>
{/each}
</div>
{/if}
</section>
{/each}
@@ -205,6 +249,31 @@
padding: 0.625rem 0.75rem;
}
.group-toggle {
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0;
border: 0;
text-align: left;
cursor: pointer;
background: transparent;
}
.chevron {
display: flex;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
&.open {
transform: rotate(180deg);
}
}
.group-name {
flex: 1;
overflow: hidden;
@@ -1,21 +1,33 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getStories } from "$lib/api/endpoints";
import { enqueueStoriesBackfill, getStories } from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Button from "$lib/components/ui/Button.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 { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId?: number | null;
}
let { chatId = null }: Props = $props();
const PAGE = 60;
const peerId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
chatId ?? (page.params.chatId ? Number(page.params.chatId) : null)
);
let backfilling = $state(false);
let items = $state<StoryView[]>([]);
let loading = $state(false);
let done = $state(false);
@@ -89,50 +101,72 @@
viewerIndex = index;
viewerOpen = true;
}
async function backfill(id: number) {
if (backfilling) {
return;
}
backfilling = true;
try {
await enqueueStoriesBackfill(id);
toasts.success("Загружаем старые сторис");
} catch {
toasts.error("Не удалось запустить загрузку сторис");
} finally {
backfilling = false;
}
}
async function reload(id: number) {
token++;
items = [];
done = false;
loading = false;
await loadMore(id);
}
</script>
{#if peerId === null}
<EmptyState title="Сторис" description="Откройте чат" />
{:else if items.length === 0}
{:else}
<div class="toolbar">
<Button
variant="secondary"
pill
smaller
loading={backfilling}
onclick={() => peerId !== null && backfill(peerId)}
>
<Icon name="cloud-download" />Загрузить старые
</Button>
<Button
variant="translucent"
round
smaller
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
aria-label="Обновить"
>
<Icon name="reload" />
</Button>
</div>
{#if items.length === 0}
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет сторис" />
<EmptyState
title="Нет сторис"
description="Нажмите «Загрузить старые», чтобы забрать архив"
/>
{/if}
{:else}
<div class="grid">
{#each items as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
preload="metadata"
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.pinned}
<span class="badge pin"
><Icon name="story-priority" size="0.875rem" /></span
>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
<StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(index)}
/>
{/each}
</div>
@@ -147,7 +181,6 @@
</button>
{/if}
{#if peerId !== null}
<StoryViewer
{peerId}
{items}
@@ -164,6 +197,14 @@
padding: 2rem 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -0,0 +1,171 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { downloadStory } from "$lib/api/download";
import type { StoryView } from "$lib/api/types";
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 { formatFull } from "$lib/format/datetime";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
onopen: () => void;
preview: string | null | undefined;
story: StoryView;
}
const { story, preview, onopen }: Props = $props();
const label = $derived(
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
);
async function save() {
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
}
}
function share() {
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
label
);
}
function openAuthor() {
goto(`/app/${story.peer_id}`);
ui.openPanel("profile");
}
</script>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="tile"
class:expired={story.deleted}
onclick={onopen}
>
{#if preview}
{#if story.media_kind === "video"}
<video
src={preview}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={preview} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if story.pinned}
<span class="badge pin">
<Icon name="story-priority" size="0.875rem" />
</span>
{/if}
{#if story.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{story.views}
</span>
{/if}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
Открыть
</ContextMenuItem>
{#if story.downloaded}
<ContextMenuItem icon="download" onselect={save}>
Скачать
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/if}
<ContextMenuItem icon="info" onselect={openAuthor}>
Профиль автора
</ContextMenuItem>
{/snippet}
</ContextMenu>
<style lang="scss">
.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;
}
</style>
@@ -1,11 +1,16 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { downloadStory } from "$lib/api/download";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
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 { formatFull } from "$lib/format/datetime";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
index: number;
@@ -22,16 +27,23 @@
}: Props = $props();
const PHOTO_SECONDS = 6;
const HOLD_MS = 180;
let url = $state<string | null>(null);
let loading = $state(false);
let ready = $state(false);
let videoProgress = $state(0);
let muted = $state(true);
let held = $state(false);
let saving = $state(false);
let video = $state<HTMLVideoElement | null>(null);
let token = 0;
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let suppressTap = false;
const story = $derived(items[index] ?? null);
const isVideo = $derived(story?.media_kind === "video");
const canSave = $derived(Boolean(story?.downloaded));
function step(delta: number) {
const next = index + delta;
@@ -49,6 +61,37 @@
step(1);
}
function startHold() {
suppressTap = false;
if (holdTimer) {
clearTimeout(holdTimer);
}
holdTimer = setTimeout(() => {
held = true;
video?.pause();
}, HOLD_MS);
}
function releaseHold() {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
if (held) {
suppressTap = true;
held = false;
video?.play().catch(() => undefined);
}
}
function onTap(delta: number) {
if (suppressTap) {
suppressTap = false;
return;
}
step(delta);
}
async function load(item: StoryView) {
loading = true;
ready = false;
@@ -63,12 +106,36 @@
}
function onVideoTime(event: Event) {
const video = event.currentTarget as HTMLVideoElement;
if (video.duration > 0) {
videoProgress = video.currentTime / video.duration;
const element = event.currentTarget as HTMLVideoElement;
if (element.duration > 0) {
videoProgress = element.currentTime / element.duration;
}
}
async function save() {
if (!story || saving) {
return;
}
saving = true;
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
} finally {
saving = false;
}
}
function share() {
if (!story) {
return;
}
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
);
}
function onkeydown(event: KeyboardEvent) {
if (!open) {
return;
@@ -77,6 +144,14 @@
step(-1);
} else if (event.key === "ArrowRight") {
step(1);
} else if (event.key === " " && !event.repeat) {
event.preventDefault();
held = !held;
if (held) {
video?.pause();
} else {
video?.play().catch(() => undefined);
}
}
}
@@ -97,17 +172,18 @@
untrack(() => {
url = null;
ready = false;
held = false;
});
}
});
</script>
<svelte:window {onkeydown} />
<svelte:window {onkeydown} onpointerup={releaseHold} />
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="story-overlay" />
<Dialog.Content class="story-content">
<Dialog.Content class="story-content {held ? 'held' : ''}">
<Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
<div class="bars">
{#each items as item, i (item.story_id)}
@@ -119,6 +195,7 @@
{#if ready && !isVideo}
<div
class="fill anim"
class:paused={held}
style="animation-duration: {PHOTO_SECONDS}s"
onanimationend={advance}
></div>
@@ -146,6 +223,29 @@
<Icon name={muted ? "speaker-muted-story" : "speaker-story"} />
</button>
{/if}
{#if canSave}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="round"
aria-label="Скачать сторис"
onclick={save}
>
<Icon name={saving ? "timer" : "download"} />
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
<Dialog.Close class="round" aria-label="Закрыть">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
@@ -158,6 +258,7 @@
{:else if url && isVideo}
<!-- biome-ignore lint/a11y/useMediaCaption: archived story has no captions -->
<video
bind:this={video}
class="media"
src={url}
autoplay
@@ -200,14 +301,22 @@
type="button"
class="tap prev"
aria-label="Назад"
onclick={() => step(-1)}
onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(-1)}
></button>
<button
type="button"
class="tap next"
aria-label="Вперёд"
onclick={() => step(1)}
onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(1)}
></button>
{#if held}
<span class="hold-hint">Пауза</span>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -268,6 +377,10 @@
&.anim {
animation: story-progress linear forwards;
}
&.paused {
animation-play-state: paused;
}
}
@keyframes story-progress {
@@ -376,6 +489,9 @@
cursor: pointer;
background: transparent;
touch-action: none;
user-select: none;
-webkit-touch-callout: none;
&.prev {
left: 0;
@@ -386,4 +502,44 @@
width: 65%;
}
}
.hold-hint {
position: absolute;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
color: var(--color-white);
letter-spacing: 0.04em;
background-color: rgba(255, 255, 255, 0.16);
backdrop-filter: blur(6px);
animation: hold-in 0.18s ease;
pointer-events: none;
z-index: 3;
}
@keyframes hold-in {
from {
opacity: 0;
transform: translate(-50%, 0.375rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
:global(.story-content.held) .bars,
:global(.story-content.held) .story-head,
:global(.story-content.held) .caption,
:global(.story-content.held) .view-count {
opacity: 0.25;
transition: opacity 0.2s;
}
</style>
@@ -1,3 +1,9 @@
<script lang="ts" module>
type PointerHandler = (event: PointerEvent) => void;
const claimed = new WeakSet<Event>();
</script>
<script lang="ts">
import { ContextMenu } from "bits-ui";
import type { Snippet } from "svelte";
@@ -8,12 +14,42 @@
}
const { children, menu }: Props = $props();
let open = $state(false);
let suppressClick = false;
function triggerProps(props: Record<string, unknown>) {
const onpointerdown = props.onpointerdown as PointerHandler;
const onpointerup = props.onpointerup as PointerHandler;
return {
...props,
onpointerdown(event: PointerEvent) {
suppressClick = false;
if (claimed.has(event)) {
return;
}
claimed.add(event);
onpointerdown(event);
},
onpointerup(event: PointerEvent) {
suppressClick = open && event.pointerType !== "mouse";
onpointerup(event);
},
onclickcapture(event: MouseEvent) {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
}
},
};
}
</script>
<ContextMenu.Root>
<ContextMenu.Root bind:open>
<ContextMenu.Trigger>
{#snippet child({ props })}
{@render children({ props })}
{@render children({ props: triggerProps(props) })}
{/snippet}
</ContextMenu.Trigger>
<ContextMenu.Portal>
@@ -0,0 +1,40 @@
<script lang="ts">
import { encode } from "uqr";
const QUIET_ZONE = 2;
let { value, label }: { value: string; label: string } = $props();
const code = $derived.by(() => {
const qr = encode(value, { ecc: "M", border: 0 });
const path = qr.data
.flatMap((row, y) =>
row.flatMap((filled, x) => (filled ? [`M${x} ${y}h1v1h-1z`] : []))
)
.join("");
return { extent: qr.size + QUIET_ZONE * 2, path };
});
</script>
<svg
class="qr"
viewBox="0 0 {code.extent} {code.extent}"
role="img"
aria-label={label}
>
<rect width={code.extent} height={code.extent} fill="var(--qr-bg, #fff)" />
<path
d={code.path}
fill="var(--qr-fg, #000)"
transform="translate({QUIET_ZONE} {QUIET_ZONE})"
/>
</svg>
<style lang="scss">
.qr {
display: block;
width: 100%;
height: auto;
border-radius: 0.75rem;
}
</style>
+15 -15
View File
@@ -1,27 +1,27 @@
const MEDIA_KIND_LABELS: Record<string, string> = {
photo: "Photo",
video: "Video",
photo: "Фото",
video: "Видео",
animation: "GIF",
gif: "GIF",
voice: "Voice message",
audio: "Audio",
video_note: "Video message",
sticker: "Sticker",
document: "File",
contact: "Contact",
location: "Location",
venue: "Location",
poll: "Poll",
dice: "Dice",
game: "Game",
story: "Story",
voice: "Голосовое",
audio: "Аудио",
video_note: "Кружок",
sticker: "Стикер",
document: "Файл",
contact: "Контакт",
location: "Геопозиция",
venue: "Геопозиция",
poll: "Опрос",
dice: "Кубик",
game: "Игра",
story: "Сторис",
};
export function mediaKindLabel(kind: string | null): string | null {
if (!kind) {
return null;
}
return MEDIA_KIND_LABELS[kind] ?? "Media";
return MEDIA_KIND_LABELS[kind] ?? "Медиа";
}
const BYTE_UNITS = ["B", "KB", "MB", "GB"];

Some files were not shown because too many files have changed in this diff Show More