Compare commits

..
16 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
87 changed files with 4866 additions and 564 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ deploy:
$(MAKE) rebuild $(MAKE) rebuild
migrate: migrate:
docker compose run --rm migrator $(filter-out $@,$(MAKECMDGOALS)) docker compose --profile db --profile migrate run --rm migrator $(filter-out $@,$(MAKECMDGOALS))
session-create: session-create:
cd backend && uv run python scripts/session/create.py cd backend && uv run python scripts/session/create.py
@@ -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,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")
+10 -2
View File
@@ -22,6 +22,7 @@ from api.routers import (
custom_emoji, custom_emoji,
discover, discover,
events, events,
files,
folders, folders,
media, media,
peers, peers,
@@ -29,11 +30,13 @@ from api.routers import (
presence, presence,
profile, profile,
search, search,
shares,
social, social,
stories, stories,
watches, watches,
) )
from dependencies.container import container from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env from utils.env import env
if env.auth.token is None: if env.auth.token is None:
@@ -87,6 +90,8 @@ app.include_router(peers.router)
app.include_router(discover.router) app.include_router(discover.router)
app.include_router(annotations.router) app.include_router(annotations.router)
app.include_router(watches.router) app.include_router(watches.router)
app.include_router(shares.router)
app.include_router(files.router)
app.mount("/mcp", mcp_app) app.mount("/mcp", mcp_app)
@@ -105,8 +110,11 @@ if _spa_dir.is_dir():
async def serve_spa(spa_path: str) -> FileResponse: async def serve_spa(spa_path: str) -> FileResponse:
candidate = (_spa_dir / spa_path).resolve() candidate = (_spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file(): if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file():
return FileResponse(candidate) immutable = spa_path.startswith("_app/immutable/")
return FileResponse(_spa_index) 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) app.add_middleware(BearerAuthMiddleware, token=_token)
+6 -1
View File
@@ -65,11 +65,16 @@ class EventHub:
return return
account_id = event.get("account_id") account_id = event.get("account_id")
chat_id = event.get("chat_id") chat_id = event.get("chat_id")
scoped = event.get("kind") == "presence"
targets = [ targets = [
sub sub
for sub in self._subscribers for sub in self._subscribers
if sub.account_id == account_id 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: if not targets:
return return
+4 -1
View File
@@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.cache import IMMUTABLE_HEADERS, SHORT_HEADERS
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar
from utils.read.models import AvatarHistoryView from utils.read.models import AvatarHistoryView
@@ -52,5 +53,7 @@ async def serve_avatar(
) )
raise HTTPException(status_code=409, detail="avatar not downloaded; fetching") raise HTTPException(status_code=409, detail="avatar not downloaded; fetching")
return FileResponse( 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 account_id: int
chat_id: int chat_id: int
media: bool = False media: bool = False
full: bool = False
class StoriesBackfillRequest(BaseModel):
account_id: int
peer_id: int
class FetchMediaRequest(BaseModel): class FetchMediaRequest(BaseModel):
@@ -70,7 +76,17 @@ async def enqueue_backfill(
pool, pool,
body.account_id, body.account_id,
"backfill", "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) return EnqueueResponse(job_id=job_id)
+18 -1
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.policy import repository
from utils.read import chats from utils.read import chats
from utils.read.models import ( from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
@@ -35,8 +36,24 @@ async def list_chats(
account_id: AccountId, account_id: AccountId,
limit: Limit = DEFAULT_LIMIT, limit: Limit = DEFAULT_LIMIT,
offset: Offset = 0, offset: Offset = 0,
folder_id: Annotated[int | None, Query()] = None,
search: Annotated[str | None, Query()] = None,
) -> list[ChatListItem]: ) -> 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") @router.get("/chats/{chat_id}/messages")
+4 -1
View File
@@ -103,7 +103,10 @@ async def track_chat(
await enqueue(pool, body.account_id, "enrich_chat", {"chat_id": chat_id}) await enqueue(pool, body.account_id, "enrich_chat", {"chat_id": chat_id})
if body.backfill: if body.backfill:
await enqueue( await enqueue(
pool, body.account_id, "backfill", {"chat_id": chat_id, "media": True} pool,
body.account_id,
"backfill",
{"chat_id": chat_id, "media": True, "full": True},
) )
return await discover.get_item(pool, body.account_id, chat_id) return await discover.get_item(pool, body.account_id, chat_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 import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse 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 ( from utils.read.media import (
get_media, get_media,
get_media_version, get_media_version,
@@ -16,6 +18,15 @@ from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute) router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
Download = Annotated[bool, Query()]
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") @router.get("/{media_id}/meta")
async def media_meta(pool: FromDishka[asyncpg.Pool], media_id: int) -> MediaView: 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], pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage], storage: FromDishka[ContentAddressedStorage],
version_id: int, version_id: int,
download: Download = False,
) -> FileResponse: ) -> FileResponse:
version = await get_media_version(pool, version_id) version = await get_media_version(pool, version_id)
if version is None: if version is None:
raise HTTPException(status_code=404, detail="media version not found") 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( return FileResponse(
storage.url(version.storage_key), 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], pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage], storage: FromDishka[ContentAddressedStorage],
media_id: int, media_id: int,
download: Download = False,
) -> FileResponse: ) -> FileResponse:
media = await get_media(pool, media_id) media = await get_media(pool, media_id)
if media is None: if media is None:
@@ -77,7 +96,11 @@ async def serve_media(
status_code=409, status_code=409,
detail="media not downloaded; enqueue fetch via POST /api/media/fetch", 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( return FileResponse(
storage.url(media.storage_key), 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,
) )
+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 import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.files import content_disposition, story_file_name
from utils.read import peers from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -36,13 +37,20 @@ async def serve_story_media(
peer_id: int, peer_id: int,
story_id: int, story_id: int,
account_id: AccountId, account_id: AccountId,
download: Annotated[bool, Query()] = False,
) -> FileResponse: ) -> FileResponse:
story = await peers.get_story(pool, account_id, peer_id, story_id) story = await peers.get_story(pool, account_id, peer_id, story_id)
if story is None: if story is None:
raise HTTPException(status_code=404, detail="story not found") raise HTTPException(status_code=404, detail="story not found")
if not story.downloaded or story.storage_key is None: if not story.downloaded or story.storage_key is None:
raise HTTPException(status_code=409, detail="story media not downloaded") 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( return FileResponse(
storage.url(story.storage_key), storage.url(story.storage_key),
media_type=_STORY_MIME.get(story.media_kind or "", "application/octet-stream"), media_type=_STORY_MIME.get(story.media_kind or "", "application/octet-stream"),
headers=headers,
) )
+7 -1
View File
@@ -11,7 +11,13 @@ from utils.events import notify_bg_event
@PyroClient.on_edited_message() @PyroClient.on_edited_message()
async def on_edited_message(client: PyroClient, message: Message) -> None: async def on_edited_message(client: PyroClient, message: Message) -> None:
ctx = client.capture 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 return
chat = message.chat chat = message.chat
chat_id = chat.id or 0 chat_id = chat.id or 0
+7 -1
View File
@@ -11,7 +11,13 @@ from utils.events import notify_bg_event
@PyroClient.on_message() @PyroClient.on_message()
async def on_message(client: PyroClient, message: Message) -> None: async def on_message(client: PyroClient, message: Message) -> None:
ctx = client.capture 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 return
meta = meta_from_chat(message.chat, ctx.contacts.ids) meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_text(meta.chat_id, message.id, message.text or message.caption) 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 pyrogram.types import Story
from userbot import PyroClient from userbot import PyroClient
from userbot.modules.stories import repository from userbot.modules.stories.service import save_story
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
@PyroClient.on_story() @PyroClient.on_story()
async def on_story(client: PyroClient, story: Story) -> None: async def on_story(client: PyroClient, story: Story) -> None:
ctx = client.capture if client.capture is None:
if ctx is None:
return return
media_kind = story.media.name.lower() if story.media else None await save_story(client, client.capture, story)
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,
)
handlers = on_story.handlers 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( async def mark_deleted_box(
pool: asyncpg.Pool, account_id: int, message_ids: list[int] pool: asyncpg.Pool, account_id: int, message_ids: list[int]
) -> None: ) -> None:
@@ -1,5 +1,6 @@
from userbot.modules.jobs.handlers import ( from userbot.modules.jobs.handlers import (
backfill, backfill,
backfill_stories,
enrich_chat, enrich_chat,
fetch_avatar, fetch_avatar,
fetch_custom_emoji, fetch_custom_emoji,
@@ -12,6 +13,7 @@ from userbot.modules.jobs.handlers import (
__all__ = [ __all__ = [
"backfill", "backfill",
"backfill_stories",
"enrich_chat", "enrich_chat",
"fetch_avatar", "fetch_avatar",
"fetch_custom_emoji", "fetch_custom_emoji",
@@ -1,7 +1,11 @@
from pyrogram import Client
from pyrogram.errors import PeerIdInvalid from pyrogram.errors import PeerIdInvalid
from pyrogram.types import Message
from userbot.modules.capture import capture_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.chat_meta import meta_from_chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
from userbot.modules.stt import repository as stt_repo from userbot.modules.stt import repository as stt_repo
@@ -12,6 +16,33 @@ from utils.policy.models import CaptureToggles
SAVE_EVERY = 100 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") @register("backfill")
async def backfill(ctx: JobContext) -> None: async def backfill(ctx: JobContext) -> None:
client = ctx.client client = ctx.client
@@ -26,24 +57,21 @@ async def backfill(ctx: JobContext) -> None:
media=bool(ctx.job.params.get("media")), media=bool(ctx.job.params.get("media")),
self_destruct_media=False, 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) 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 self_id = client.me.id if client.me else None
try: 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) await capture_message(client, message, capture, toggles)
if should_transcribe_on_backfill(message, self_id) and message.chat: await maybe_transcribe(client, capture, chat_id, message, self_id)
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)
processed += 1 processed += 1
if processed % SAVE_EVERY == 0: if processed % SAVE_EVERY == 0:
next_max = message.id - 1 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}) await ctx.report_progress({"processed": processed, "max_id": next_max})
if await ctx.is_canceled(): if await ctx.is_canceled():
return 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})
@@ -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 async def upsert_story( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, 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,
)
+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(), 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)
+12
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] 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( async def create_policy(
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int | None, account_id: int | None,
+111 -60
View File
@@ -1,5 +1,6 @@
import asyncpg import asyncpg
from utils.policy.models import FolderSpec
from utils.read.accounts import self_user_id 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.message_view import build_message_view, load_raw, media_ref_from
from utils.read.models import ( from utils.read.models import (
@@ -43,69 +44,88 @@ def _single_media(
return [ref] if ref else [] return [ref] if ref else []
def _peer_title( _ALL_IDS = """
first: str | None, last: str | None, username: str | None SELECT chat_id FROM chat_stats WHERE account_id = $1
) -> str | None: UNION
name = " ".join(part for part in (first, last) if part) SELECT chat_id FROM dialogs WHERE account_id = $1
return name or username 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( def _folder_filter(base: int) -> str:
pool: asyncpg.Pool, account_id: int, page: Page return (
) -> list[ChatListItem]: f"NOT (chat.chat_id = ANY(${base + 1}::bigint[])) "
rows = await pool.fetch( f"AND (chat.chat_id = ANY(${base + 2}::bigint[]) "
"WITH ids AS (" f"OR (NOT ${base + 3}::bool AND CASE "
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 " f"WHEN chat.is_broadcast THEN ${base + 4}::bool "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1 " f"WHEN chat.chat_id < 0 THEN ${base + 5}::bool "
"UNION SELECT scope_id FROM capture_policy WHERE account_id = $1 " f"WHEN chat.is_bot THEN ${base + 6}::bool "
"AND scope_type = 'chat' AND scope_id IS NOT NULL), " f"WHEN chat.is_contact THEN ${base + 7}::bool "
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date " f"ELSE ${base + 8}::bool END))"
"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,
) )
items = []
for row in rows:
title = row["group_title"] or _peer_title( def _folder_params(folder: FolderSpec) -> list[object]:
row["first_name"], row["last_name"], row["username"] return [
) sorted(folder.exclude_ids),
items.append( sorted(folder.include_ids | folder.pinned_ids),
ChatListItem( 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"], chat_id=row["chat_id"],
title=title, title=row["title"],
kind="private" if row["chat_id"] > 0 else "group", kind="private" if row["chat_id"] > 0 else "group",
has_avatar=row["has_avatar"], has_avatar=row["has_avatar"],
is_bot=bool(row["is_bot"]), is_bot=bool(row["is_bot"]),
@@ -116,8 +136,39 @@ async def list_chats(
last_text=row["last_text"], last_text=row["last_text"],
last_sender_id=row["last_sender_id"], 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 async def get_chat_history( # noqa: PLR0913
+34 -8
View File
@@ -1,18 +1,41 @@
import asyncpg import asyncpg
from utils.files import media_file_name
from utils.read.message_view import load_raw from utils.read.message_view import load_raw
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaVersionView, MediaView
_MEDIA_COLS = ( MEDIA_COLS = (
"id, account_id, chat_id, message_id, kind, storage_key, file_size, " "m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, "
"mime, ttl_seconds, downloaded, extracted_text, created_at" "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" _VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
_WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio") _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( async def _web_page_media_stub(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MediaView | None: ) -> MediaView | None:
@@ -47,29 +70,32 @@ async def _web_page_media_stub(
downloaded=False, downloaded=False,
extracted_text=None, extracted_text=None,
created_at=row["date"], 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: async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
row = await pool.fetchrow( 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, media_id,
) )
return MediaView(**dict(row)) if row else None return media_view(row) if row else None
async def get_message_media( async def get_message_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MediaView | None: ) -> MediaView | None:
row = await pool.fetchrow( row = await pool.fetchrow(
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608 f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3", "WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3",
account_id, account_id,
chat_id, chat_id,
message_id, message_id,
) )
if row is not None: 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) 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 import asyncpg
from pydantic import ValidationError from pydantic import ValidationError
from utils.files import media_file_name
from utils.read.models import ( from utils.read.models import (
ContactView, ContactView,
EntityView, EntityView,
@@ -344,6 +345,7 @@ def media_ref_from(
obj = obj if isinstance(obj, dict) else {} obj = obj if isinstance(obj, dict) else {}
width = obj.get("width") or obj.get("length") width = obj.get("width") or obj.get("length")
height = obj.get("height") 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( return MediaRef(
message_id=message_id, message_id=message_id,
id=media_row["id"] if media_row else None, id=media_row["id"] if media_row else None,
@@ -352,11 +354,12 @@ def media_ref_from(
width=width, width=width,
height=height, height=height,
duration=obj.get("duration"), 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) file_size=(media_row["file_size"] if media_row else None)
or obj.get("file_size"), or obj.get("file_size"),
ttl_seconds=media_row["ttl_seconds"] if media_row else None, ttl_seconds=media_row["ttl_seconds"] if media_row else None,
extracted_text=media_row["extracted_text"] 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")),
) )
+32
View File
@@ -92,6 +92,7 @@ class MediaRef(BaseModel):
file_size: int | None = None file_size: int | None = None
ttl_seconds: int | None = None ttl_seconds: int | None = None
extracted_text: str | None = None extracted_text: str | None = None
file_name: str | None = None
class ReactionCount(BaseModel): class ReactionCount(BaseModel):
@@ -222,6 +223,7 @@ class MediaView(BaseModel):
downloaded: bool downloaded: bool
extracted_text: str | None extracted_text: str | None
created_at: datetime created_at: datetime
file_name: str | None = None
class MediaVersionView(BaseModel): class MediaVersionView(BaseModel):
@@ -391,3 +393,33 @@ class AlertView(BaseModel):
payload: dict[str, Any] payload: dict[str, Any]
seen: bool seen: bool
created_at: datetime 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 import asyncpg
from utils.read.accounts import self_user_id 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 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( async def chat_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, kinds: list[str], page: Page pool: asyncpg.Pool, account_id: int, chat_id: int, kinds: list[str], page: Page
) -> list[MediaView]: ) -> list[MediaView]:
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608 f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND kind = ANY($3) " "WHERE m.account_id = $1 AND m.chat_id = $2 AND m.kind = ANY($3) "
"ORDER BY message_id DESC LIMIT $4 OFFSET $5", "ORDER BY m.message_id DESC LIMIT $4 OFFSET $5",
account_id, account_id,
chat_id, chat_id,
kinds, kinds,
page.capped_limit, page.capped_limit,
page.offset, page.offset,
) )
return [MediaView(**dict(row)) for row in rows] return [media_view(row) for row in rows]
async def chat_links( 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]
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "frontend", "name": "frontend",
+19 -2
View File
@@ -6,6 +6,8 @@ const RETRY_DELAY = 2500;
export type AvatarKind = "peer" | "chat"; export type AvatarKind = "peer" | "chat";
const MAX_CACHED = 240;
const ready = new Map<string, string>(); const ready = new Map<string, string>();
const missing = new Set<string>(); const missing = new Set<string>();
const inflight = new Map<string, Promise<string | null>>(); 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}`; 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> { function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {}; return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
} }
@@ -35,7 +52,7 @@ async function fetchAvatar(
const response = await fetch(url, { headers: authHeaders() }); const response = await fetch(url, { headers: authHeaders() });
if (response.ok) { if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob()); const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl); remember(key, objectUrl);
return objectUrl; return objectUrl;
} }
if (response.status === 409 && retry) { if (response.status === 409 && retry) {
@@ -85,7 +102,7 @@ async function fetchVariant(
const response = await fetch(url, { headers: authHeaders() }); const response = await fetch(url, { headers: authHeaders() });
if (response.ok) { if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob()); const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl); remember(key, objectUrl);
return objectUrl; return objectUrl;
} }
if (response.status === 409 && retry) { 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}`
);
}
+23 -4
View File
@@ -99,10 +99,19 @@ export function logoutAccount(accountId: number): Promise<void> {
return request<void>(`/accounts/${accountId}`, { method: "DELETE" }); return request<void>(`/accounts/${accountId}`, { method: "DELETE" });
} }
export function listChats(page: Page = {}): Promise<Chat[]> { interface ChatPage extends Page {
folder_id?: number;
search?: string;
}
export function listChats(page: ChatPage = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } }); 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[]> { export function listFolders(): Promise<Folder[]> {
return request<Folder[]>("/folders", { account: true }); return request<Folder[]>("/folders", { account: true });
} }
@@ -306,7 +315,7 @@ export function getMessageAt(chatId: number, date: string): Promise<MessageAt> {
} }
export function getStories( export function getStories(
peerId: number, peerId: number | null,
page: Page = {} page: Page = {}
): Promise<StoryView[]> { ): Promise<StoryView[]> {
return request<StoryView[]>("/stories", { return request<StoryView[]>("/stories", {
@@ -349,11 +358,21 @@ export function listJobs(status?: JobStatus): Promise<JobView[]> {
export function enqueueBackfill( export function enqueueBackfill(
chatId: number, chatId: number,
media: boolean media: boolean,
full = false
): Promise<{ job_id: number }> { ): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/backfill", { return request<{ job_id: number }>("/backfill", {
method: "POST", 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 },
}); });
} }
+37 -1
View File
@@ -20,9 +20,12 @@ export type InlineMedia =
export interface ViewerItem { export interface ViewerItem {
downloaded: boolean; downloaded: boolean;
fileName: string | null;
fileSize: number | null;
kind: string; kind: string;
mediaId: number | null; mediaId: number | null;
messageId: number; messageId: number;
mime: string | null;
} }
export function viewerItemsFrom( export function viewerItemsFrom(
@@ -30,16 +33,49 @@ export function viewerItemsFrom(
media: MediaRef[] media: MediaRef[]
): ViewerItem[] { ): ViewerItem[] {
if (media.length === 0) { 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) => ({ return media.map((item) => ({
messageId: item.message_id, messageId: item.message_id,
mediaId: item.id, mediaId: item.id,
kind: item.kind, kind: item.kind,
downloaded: item.downloaded, 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"; export type VisualKind = "image" | "video" | "other";
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]); 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";
}
+40
View File
@@ -92,6 +92,7 @@ export interface ForwardView {
export interface MediaRef { export interface MediaRef {
downloaded: boolean; downloaded: boolean;
duration: number | null; duration: number | null;
file_name: string | null;
file_size: number | null; file_size: number | null;
height: number | null; height: number | null;
id: number | null; id: number | null;
@@ -242,6 +243,7 @@ export interface MediaView {
created_at: string; created_at: string;
downloaded: boolean; downloaded: boolean;
extracted_text: string | null; extracted_text: string | null;
file_name: string | null;
file_size: number | null; file_size: number | null;
id: number; id: number;
kind: string; kind: string;
@@ -506,3 +508,41 @@ export type LiveEvent =
| LiveDeleteEvent | LiveDeleteEvent
| LivePresenceEvent | LivePresenceEvent
| LiveReceiptEvent; | 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;
}
+14 -18
View File
@@ -12,11 +12,10 @@
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer"; 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 { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { discover } from "$lib/stores/discover.svelte"; import { discover } from "$lib/stores/discover.svelte";
import { events } from "$lib/stores/events.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte"; import { ui } from "$lib/stores/ui.svelte";
@@ -26,6 +25,8 @@
let { chatId }: Props = $props(); let { chatId }: Props = $props();
const PRESENCE_INTERVAL = 30_000;
const isDm = $derived(chatId > 0); const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId)); const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId)); const discovered = $derived(discover.get(chatId));
@@ -40,7 +41,7 @@
backfilling = true; backfilling = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true);
toasts.success("Бэкфилл запущен"); toasts.success("Догружаем новые сообщения");
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error("Не удалось запустить бэкфилл");
} finally { } finally {
@@ -85,6 +86,10 @@
} }
let active = true; let active = true;
presence = null; presence = null;
const refresh = () => {
if (document.visibilityState !== "visible") {
return;
}
getCurrentPresence(chatId) getCurrentPresence(chatId)
.then((result) => { .then((result) => {
if (active) { if (active) {
@@ -96,18 +101,12 @@
presence = null; presence = null;
} }
}); });
const unsub = events.subscribe((event) => { };
if ( refresh();
event.type === "presence" && const timer = setInterval(refresh, PRESENCE_INTERVAL);
event.peer_id === chatId &&
event.sample
) {
presence = event.sample;
}
});
return () => { return () => {
active = false; active = false;
unsub(); clearInterval(timer);
}; };
}); });
@@ -170,10 +169,7 @@
/> />
<div class="info"> <div class="info">
<h2 class="title">{title}</h2> <h2 class="title">{title}</h2>
<span <span class="subtitle" class:online={isDm && isOnline(presence)}>
class="subtitle"
class:online={isDm && presence?.status === "online"}
>
{subtitle} {subtitle}
</span> </span>
</div> </div>
@@ -215,7 +211,7 @@
smaller smaller
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
aria-label="Скачать историю" aria-label="Догрузить новые сообщения"
> >
<Icon name="cloud-download" /> <Icon name="cloud-download" />
</Button> </Button>
+78 -30
View File
@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import { cubicOut } from "svelte/easing"; import { untrack } from "svelte";
import { fly } from "svelte/transition";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import ChatListItem from "$lib/components/ChatListItem.svelte"; import ChatListItem from "$lib/components/ChatListItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Skeleton from "$lib/components/ui/Skeleton.svelte"; import Skeleton from "$lib/components/ui/Skeleton.svelte";
import { folderContains } from "$lib/format/folders";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { folders } from "$lib/stores/folders.svelte"; import { folders } from "$lib/stores/folders.svelte";
@@ -14,37 +12,94 @@
const skeletonRows = Array.from({ length: 9 }, (_, index) => index); const skeletonRows = Array.from({ length: 9 }, (_, index) => index);
const DEFAULT_ROW_HEIGHT = 72;
const OVERSCAN = 6;
const SCROLL_THRESHOLD = 600;
const activeChatId = $derived( const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
); );
const selectedFolder = $derived(folders.selected); let viewport = $state<HTMLDivElement | null>(null);
const visibleChats = $derived( let viewportHeight = $state(0);
selectedFolder === null let scrollTop = $state(0);
? chats.list let rowHeight = $state(DEFAULT_ROW_HEIGHT);
: chats.list.filter((chat) => folderContains(selectedFolder, chat)) 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(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
} }
chats.load().catch(() => toasts.error("Failed to load chats")); untrack(() => folders.load()).catch(() =>
folders.load().catch(() => toasts.error("Failed to load folders")); 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) { function onScroll(event: Event) {
const el = event.currentTarget as HTMLElement; const el = event.currentTarget as HTMLElement;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) { if (frame) {
chats.loadMore().catch(() => undefined); return;
} }
frame = requestAnimationFrame(() => {
frame = 0;
measure(el);
});
} }
</script> </script>
<div class="chat-list custom-scroll" onscroll={onScroll}> <div
{#if chats.loading && chats.list.length === 0} bind:this={viewport}
bind:clientHeight={viewportHeight}
class="chat-list custom-scroll"
onscroll={onScroll}
>
{#if chats.loading && list.length === 0}
{#each skeletonRows as index (index)} {#each skeletonRows as index (index)}
<div class="row-skeleton"> <div class="row-skeleton">
<Skeleton width="3rem" height="3rem" circle /> <Skeleton width="3rem" height="3rem" circle />
@@ -54,30 +109,23 @@
</div> </div>
</div> </div>
{/each} {/each}
{:else if chats.list.length === 0} {:else if 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}
<EmptyState <EmptyState
title="Empty folder" title={folders.selectedId === null ? "No chats yet" : "Empty folder"}
description="No chats match this folder yet" description={folders.selectedId === null
? undefined
: "No chats match this folder yet"}
/> />
{:else} {: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 <ChatListItem
{chat} {chat}
selected={chat.chat_id === activeChatId} selected={chat.chat_id === activeChatId}
onclick={() => goto(`/app/${chat.chat_id}`)} onclick={() => goto(`/app/${chat.chat_id}`)}
/> />
{/each} {/each}
{/if}
</div> </div>
{/key}
{/if} {/if}
</div> </div>
@@ -119,6 +119,7 @@
gap: 0.625rem; gap: 0.625rem;
width: 100%; width: 100%;
height: 4.5rem;
padding: 0.5625rem 0.5rem; padding: 0.5625rem 0.5rem;
border: 0; border: 0;
border-radius: 0.625rem; border-radius: 0.625rem;
+20 -1
View File
@@ -69,13 +69,14 @@
{:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"} {:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"}
<a <a
class="link" class="link"
class:own
href={linkHref(node)} href={linkHref(node)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>{@render tree(node.children)}</a >{@render tree(node.children)}</a
> >
{:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"} {: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} {:else}
{@render tree(node.children)} {@render tree(node.children)}
{/if} {/if}
@@ -108,6 +109,24 @@
&:hover { &:hover {
text-decoration: underline; 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, .code,
+25 -3
View File
@@ -1,14 +1,21 @@
<script lang="ts"> <script lang="ts">
import { isPreviewable } from "$lib/api/media";
import type { MediaRef } from "$lib/api/types"; import type { MediaRef } from "$lib/api/types";
import AlbumTile from "$lib/components/media/AlbumTile.svelte"; import AlbumTile from "$lib/components/media/AlbumTile.svelte";
import FileChip from "$lib/components/media/FileChip.svelte";
interface Props { interface Props {
chatId: number; chatId: number;
media: MediaRef[]; media: MediaRef[];
onopen: (index: number) => void; 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 columns = $derived.by(() => {
const count = media.length; const count = media.length;
@@ -22,13 +29,28 @@
}); });
</script> </script>
<div class="MediaAlbum" style:--cols={columns}> {#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)} {#each media as item, index (item.id ?? index)}
<AlbumTile media={item} {chatId} onopen={() => onopen(index)} /> <AlbumTile media={item} {chatId} onopen={() => onopen(index)} />
{/each} {/each}
</div> </div>
{/if}
<style lang="scss"> <style lang="scss">
.AlbumFiles {
display: flex;
flex-direction: column;
gap: 0.125rem;
margin-bottom: 0.25rem;
}
.MediaAlbum { .MediaAlbum {
display: grid; display: grid;
grid-template-columns: repeat(var(--cols), 1fr); grid-template-columns: repeat(var(--cols), 1fr);
@@ -4,6 +4,7 @@
import type { MediaVersion } from "$lib/api/types"; import type { MediaVersion } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { poster } from "$lib/media/poster";
interface Props { interface Props {
version: MediaVersion; version: MediaVersion;
@@ -37,7 +38,13 @@
</a> </a>
{:else if result.state === "ready" && vk === "video"} {:else if result.state === "ready" && vk === "video"}
<a href={result.url} target="_blank" rel="noopener"> <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> <span class="play"><Icon name="large-play" size="1.5rem" /></span>
</a> </a>
{:else if result.state === "ready"} {:else if result.state === "ready"}
+196 -22
View File
@@ -2,11 +2,17 @@
import { Dialog } from "bits-ui"; import { Dialog } from "bits-ui";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "$lib/api/client"; import { type MediaResult, requestMedia } from "$lib/api/client";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints"; 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 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 Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
interface Props { interface Props {
@@ -25,8 +31,13 @@
let kind = $state(""); let kind = $state("");
let messageId = $state<number | null>(null); 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 result = $state<MediaResult | null>(null);
let loading = $state(false); let loading = $state(false);
let saving = $state(false);
let token = 0; let token = 0;
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : ""); const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
@@ -38,9 +49,13 @@
mime.startsWith("audio/") || kind === "voice" || kind === "audio" mime.startsWith("audio/") || kind === "voice" || kind === "audio"
); );
const hasNav = $derived(items.length > 1); const hasNav = $derived(items.length > 1);
const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready")
);
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
function revoke() { function revoke() {
if (result?.state === "ready") { if (result?.state === "ready" && result.url) {
URL.revokeObjectURL(result.url); URL.revokeObjectURL(result.url);
} }
} }
@@ -51,21 +66,41 @@
result = null; result = null;
kind = item.kind; kind = item.kind;
messageId = item.messageId; messageId = item.messageId;
currentMediaId = null;
fileName = item.fileName;
fileSize = item.fileSize;
fileOnly = false;
const current = ++token; const current = ++token;
try { try {
let mediaId = item.mediaId; let mediaId = item.mediaId;
let downloaded = item.downloaded; let downloaded = item.downloaded;
let name = item.fileName;
let size = item.fileSize;
let mimeType = item.mime;
if (mediaId === null) { if (mediaId === null) {
const meta = await getMessageMedia(chatId, item.messageId); const meta = await getMessageMedia(chatId, item.messageId);
mediaId = meta.id; mediaId = meta.id;
downloaded = meta.downloaded; downloaded = meta.downloaded;
kind = meta.kind; 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) { if (current === token) {
result = next; result = next;
currentMediaId = mediaId;
fileName = name;
fileSize = size;
fileOnly = plainFile;
} }
} catch { } catch {
if (current === token) { if (current === token) {
@@ -84,9 +119,29 @@
} }
try { try {
await fetchMedia(chatId, messageId); await fetchMedia(chatId, messageId);
toasts.success("Download queued"); toasts.success("Скачивание поставлено в очередь");
} catch { } 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.Portal>
<Dialog.Overlay class="media-overlay" /> <Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content"> <Dialog.Content class="media-content">
<Dialog.Title class="media-title">{kind || "Media"}</Dialog.Title> <Dialog.Title class="media-title">{title}</Dialog.Title>
<Dialog.Close class="media-close" aria-label="Close"> <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" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
</div>
{#if hasNav} {#if hasNav}
<span class="media-counter">{index + 1} / {items.length}</span> <span class="media-counter">{index + 1} / {items.length}</span>
{/if} {/if}
@@ -149,24 +237,38 @@
{:else if result?.state === "ready" && isVideo} {:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<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} {:else if result?.state === "ready" && isAudio}
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<audio src={result.url} controls></audio> <audio src={result.url} controls></audio>
{:else if result?.state === "ready"} {: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" /> <Icon name="download" />
Download file Скачать файл
</a> </button>
</div>
{:else if result?.state === "not-downloaded"} {:else if result?.state === "not-downloaded"}
<div class="media-message"> <div class="media-message">
<p>This media has not been downloaded yet.</p> <p>Файл ещё не скачан в архив.</p>
<Button variant="primary" fluid onclick={queueFetch}> <Button variant="primary" fluid onclick={queueFetch}>
Fetch media Скачать в архив
</Button> </Button>
</div> </div>
{:else if result?.state === "missing"} {:else if result?.state === "missing"}
<p class="media-message">Media not found.</p> <p class="media-message">Файл не найден.</p>
{/if} {/if}
</div> </div>
{#if hasNav} {#if hasNav}
@@ -214,14 +316,18 @@
:global(.media-title) { :global(.media-title) {
position: absolute; position: absolute;
top: 1rem; top: 1.25rem;
left: 1.25rem; left: 1.25rem;
overflow: hidden;
max-width: min(24rem, calc(100% - 14rem));
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-white); color: var(--color-white);
text-transform: capitalize; text-overflow: ellipsis;
white-space: nowrap;
} }
.media-counter { .media-counter {
@@ -236,9 +342,6 @@
:global(.media-close) { :global(.media-close) {
cursor: pointer; cursor: pointer;
position: absolute;
top: 0.75rem;
right: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -274,13 +377,84 @@
text-align: center; 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 { .media-download {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; 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); 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 { .media-nav {
@@ -188,6 +188,7 @@
media={message.media} media={message.media}
chatId={message.chat_id} chatId={message.chat_id}
onopen={onmedia} onopen={onmedia}
{own}
/> />
{:else if message.has_media} {:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} /> <MessageMedia {message} {own} onopen={() => onmedia(0)} />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick, untrack } from "svelte";
import { listMessages } from "$lib/api/endpoints"; import { listMessages } from "$lib/api/endpoints";
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media"; import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
import type { LiveEvent, MessageView } from "$lib/api/types"; import type { LiveEvent, MessageView } from "$lib/api/types";
@@ -13,7 +13,6 @@
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatDay } from "$lib/format/datetime"; import { formatDay } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { events } from "$lib/stores/events.svelte"; import { events } from "$lib/stores/events.svelte";
import { peers } from "$lib/stores/peers.svelte"; import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -425,14 +424,10 @@
}); });
$effect(() => { $effect(() => {
const deps = { if (accounts.selectedId === null) {
account: accounts.selectedId,
revision: chats.revision,
};
if (deps.account === null) {
return; return;
} }
loadInitial(); untrack(() => loadInitial());
}); });
</script> </script>
+71 -12
View File
@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { visible } from "$lib/actions/visible"; import { visible } from "$lib/actions/visible";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia } from "$lib/api/endpoints"; import { fetchMedia } from "$lib/api/endpoints";
import { import {
type InlineMedia, type InlineMedia,
isPreviewable,
loadInlineMedia, loadInlineMedia,
visualKind, visualKind,
} from "$lib/api/media"; } from "$lib/api/media";
import type { MessageView } from "$lib/api/types"; import type { MessageView } from "$lib/api/types";
import AudioFile from "$lib/components/media/AudioFile.svelte"; 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 TgsSticker from "$lib/components/media/TgsSticker.svelte";
import VideoNote from "$lib/components/media/VideoNote.svelte"; import VideoNote from "$lib/components/media/VideoNote.svelte";
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte"; import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
@@ -15,6 +18,9 @@
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte"; import { ui } from "$lib/stores/ui.svelte";
@@ -32,9 +38,15 @@
let loaded = $state(false); let loaded = $state(false);
let media = $state<InlineMedia | null>(null); let media = $state<InlineMedia | null>(null);
let queuing = $state(false); let queuing = $state(false);
let 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 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 mime = $derived(ready?.mime ?? "");
const isImage = $derived(kind === "photo"); const isImage = $derived(kind === "photo");
const isStaticSticker = $derived( const isStaticSticker = $derived(
@@ -55,6 +67,10 @@
const label = $derived( const label = $derived(
media && media.state !== "missing" ? media.kind : "media" 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> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -63,6 +79,10 @@
} }
async function start() { async function start() {
if (asFile) {
loaded = true;
return;
}
media = await loadInlineMedia(message.chat_id, message.message_id); media = await loadInlineMedia(message.chat_id, message.message_id);
loaded = true; loaded = true;
} }
@@ -85,14 +105,34 @@
queuing = true; queuing = true;
try { try {
await fetchMedia(message.chat_id, message.message_id); await fetchMedia(message.chat_id, message.message_id);
toasts.success("Download queued"); toasts.success("Скачивание поставлено в очередь");
poll(); poll();
} catch { } catch {
toasts.error("Failed to queue download"); toasts.error("Не удалось поставить в очередь");
} finally { } finally {
queuing = false; 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> </script>
<ContextMenu> <ContextMenu>
@@ -101,8 +141,10 @@
{#if message.is_self_destruct} {#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button"> <button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" /> <Icon name="timer" size="1.25rem" />
<span>Self-destruct media</span> <span>Самоуничтожающееся медиа</span>
</button> </button>
{:else if asFile && ref}
<FileChip media={ref} {own} />
{:else if !loaded} {:else if !loaded}
<div class="media-skeleton"><Spinner /></div> <div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"} {:else if ready && kind === "voice"}
@@ -116,7 +158,7 @@
{:else if ready && kind === "video_note"} {:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} /> <VideoNote url={ready.url} transcript={ready.transcript} />
{:else if ready && kind === "audio"} {: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} {:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment"> <img src={ready.url} alt="attachment">
@@ -143,7 +185,13 @@
</button> </button>
{:else if ready && isThumbVideo} {:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button"> <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> <span class="play"><Icon name="large-play" size="2.5rem" /></span>
</button> </button>
{:else if ready} {:else if ready}
@@ -154,32 +202,43 @@
{:else if media?.state === "not-downloaded" && vk !== "other"} {:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button"> <button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" /> <Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Video" : "Photo"}</span> <span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "Queued" : "Tap to download"}</small> <small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
</button> </button>
{:else if media?.state === "not-downloaded"} {:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button"> <button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" /> <Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "Queued" : `Download ${label}`}</span> <span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
</button> </button>
{:else} {:else}
<button class="media-chip" onclick={onopen} type="button"> <button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" /> <Icon name="photo" size="1.25rem" />
<span>Media</span> <span>Медиа</span>
</button> </button>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen} <ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть</ContextMenuItem >Открыть на весь экран</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="recent" icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)} onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem >Версии медиа</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} {/snippet}
</ContextMenu> </ContextMenu>
@@ -6,6 +6,7 @@
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte"; import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
import ProfilePanel from "$lib/components/profile/ProfilePanel.svelte"; import ProfilePanel from "$lib/components/profile/ProfilePanel.svelte";
import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.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 CallbacksPanel from "$lib/components/social/CallbacksPanel.svelte";
import LinksPanel from "$lib/components/social/LinksPanel.svelte"; import LinksPanel from "$lib/components/social/LinksPanel.svelte";
import ReactionsPanel from "$lib/components/social/ReactionsPanel.svelte"; import ReactionsPanel from "$lib/components/social/ReactionsPanel.svelte";
@@ -31,6 +32,7 @@
policy: "Политика захвата", policy: "Политика захвата",
watches: "Отслеживания", watches: "Отслеживания",
alerts: "Алерты", alerts: "Алерты",
shares: "Файлы по ссылке",
}; };
</script> </script>
@@ -74,6 +76,8 @@
<AlertsPanel /> <AlertsPanel />
{:else if ui.rightPanel === "annotations"} {:else if ui.rightPanel === "annotations"}
<AnnotationsPanel /> <AnnotationsPanel />
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{/if} {/if}
</div> </div>
+2 -2
View File
@@ -48,8 +48,8 @@
} }
busy = true; busy = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true, true);
toasts.success("Бэкфилл запущен"); toasts.success("Полный бэкфилл запущен");
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error("Не удалось запустить бэкфилл");
} finally { } finally {
@@ -16,6 +16,7 @@
const KIND_LABELS: Record<string, string> = { const KIND_LABELS: Record<string, string> = {
backfill: "Бэкфилл", backfill: "Бэкфилл",
backfill_stories: "Бэкфилл сторис",
fetch_media: "Докачка медиа", fetch_media: "Докачка медиа",
fetch_avatar: "Аватар", fetch_avatar: "Аватар",
fetch_custom_emoji: "Кастом-эмодзи", fetch_custom_emoji: "Кастом-эмодзи",
@@ -79,17 +80,21 @@
schedule(); schedule();
} }
function kindLabel(kind: string): string { function kindLabel(job: JobView): string {
return KIND_LABELS[kind] ?? kind; 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 { 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; return typeof value === "number" ? value : null;
} }
function chatId(job: JobView): number | 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; return typeof value === "number" ? value : null;
} }
@@ -117,7 +122,7 @@
{#each jobs as job (job.id)} {#each jobs as job (job.id)}
<div class="job"> <div class="job">
<div class="job-head"> <div class="job-head">
<span class="kind">{kindLabel(job.kind)}</span> <span class="kind">{kindLabel(job)}</span>
{#if canCancel(job)} {#if canCancel(job)}
<button <button
type="button" type="button"
@@ -9,6 +9,7 @@
import JobList from "$lib/components/jobs/JobList.svelte"; import JobList from "$lib/components/jobs/JobList.svelte";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { createChatPicker } from "$lib/stores/chat-picker.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -23,13 +24,18 @@
let syncing = $state(false); let syncing = $state(false);
let syncingContacts = $state(false); let syncingContacts = $state(false);
const availableChats = $derived( const picker = createChatPicker();
chats.list const availableChats = $derived(picker.results);
.filter((c) =>
(c.title ?? "").toLowerCase().includes(filter.trim().toLowerCase()) $effect(() => {
) picker.search(filter);
.slice(0, 40) });
);
$effect(() => {
if (selected !== null) {
chats.ensure(selected);
}
});
function chatTitle(id: number | null): string { function chatTitle(id: number | null): string {
if (id === null) { if (id === null) {
@@ -50,8 +56,8 @@
} }
starting = true; starting = true;
try { try {
await enqueueBackfill(selected, media); await enqueueBackfill(selected, media, true);
toasts.success("Бэкфилл запущен"); toasts.success("Полный бэкфилл запущен");
version += 1; version += 1;
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error("Не удалось запустить бэкфилл");
@@ -129,6 +135,10 @@
<section> <section>
<div class="section-title">Бэкфилл</div> <div class="section-title">Бэкфилл</div>
<p class="hint">
Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в
шапке чата догружает только сообщения новее последнего сохранённого.
</p>
<button <button
type="button" type="button"
@@ -186,7 +196,7 @@
onclick={start} onclick={start}
> >
<Icon name="cloud-download" /> <Icon name="cloud-download" />
<span>Запустить бэкфилл</span> <span>Запустить полный бэкфилл</span>
</Button> </Button>
</div> </div>
</section> </section>
@@ -5,6 +5,7 @@
import type { MediaRef } from "$lib/api/types"; import type { MediaRef } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { poster } from "$lib/media/poster";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
interface Props { interface Props {
@@ -46,7 +47,13 @@
<div class="AlbumTile" use:visible={start}> <div class="AlbumTile" use:visible={start}>
{#if ready && isVideo} {#if ready && isVideo}
<button class="tile" onclick={onopen} type="button"> <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> <span class="play"><Icon name="large-play" size="2rem" /></span>
</button> </button>
{:else if ready} {: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 Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration"; import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback"; import { claimPlayback, releasePlayback } from "$lib/media/playback";
import { POSTER_TIME, poster } from "$lib/media/poster";
interface Props { interface Props {
transcript?: string | null; transcript?: string | null;
@@ -52,12 +53,13 @@
onended={() => element && releasePlayback(element)} onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)} onplay={() => element && claimPlayback(element)}
playsinline playsinline
preload="metadata" preload="auto"
src={url} src={url}
use:poster
></video> ></video>
<svg class="ring" viewBox="0 0 200 200" aria-hidden="true"> <svg class="RoundVideoRing" viewBox="0 0 200 200" aria-hidden="true">
<circle <circle
class="ring-progress" class="RoundVideoProgress"
cx="100" cx="100"
cy="100" cy="100"
r={RADIUS} r={RADIUS}
@@ -70,7 +72,7 @@
{/if} {/if}
<span class="badge"> <span class="badge">
<Icon name="microphone" size="0.875rem" /> <Icon name="microphone" size="0.875rem" />
{formatDuration(paused && currentTime === 0 ? duration : remaining)} {formatDuration(paused && currentTime <= POSTER_TIME ? duration : remaining)}
</span> </span>
</button> </button>
{#if transcript} {#if transcript}
@@ -137,20 +139,25 @@
height: 13rem; height: 13rem;
padding: 0; padding: 0;
border: 0; border: 0;
border-radius: 50%;
background: transparent; background: transparent;
-webkit-tap-highlight-color: transparent;
} }
video { video {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
background-color: var(--color-default-shadow); background-color: var(--color-default-shadow);
clip-path: circle(50%);
} }
.ring { .RoundVideoRing {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -160,7 +167,7 @@
height: 100%; height: 100%;
} }
.ring-progress { .RoundVideoProgress {
fill: transparent; fill: transparent;
stroke: var(--color-white); stroke: var(--color-white);
stroke-width: 4; stroke-width: 4;
@@ -16,6 +16,7 @@
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { createChatPicker } from "$lib/stores/chat-picker.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -79,15 +80,25 @@
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id) (f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
) )
); );
const picker = createChatPicker();
const availableChats = $derived( const availableChats = $derived(
chats.list picker.results.filter(
.filter((c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)) (c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)
.filter((c) =>
(c.title ?? "").toLowerCase().includes(chatFilter.trim().toLowerCase())
) )
.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 { function folderTitle(id: number | null): string {
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`; return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
} }
@@ -6,19 +6,21 @@
import ProfileInfo from "$lib/components/profile/ProfileInfo.svelte"; import ProfileInfo from "$lib/components/profile/ProfileInfo.svelte";
import SharedLinks from "$lib/components/profile/SharedLinks.svelte"; import SharedLinks from "$lib/components/profile/SharedLinks.svelte";
import SharedMedia from "$lib/components/profile/SharedMedia.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 Avatar from "$lib/components/ui/Avatar.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer"; import { peerName } from "$lib/format/peer";
import { chats } from "$lib/stores/chats.svelte"; 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 }[] = [ const TABS: { id: Tab; icon: string; label: string }[] = [
{ id: "info", icon: "info", label: "Инфо" }, { id: "info", icon: "info", label: "Инфо" },
{ id: "media", icon: "photo", label: "Медиа" }, { id: "media", icon: "photo", label: "Медиа" },
{ id: "files", icon: "document", label: "Файлы" }, { id: "files", icon: "document", label: "Файлы" },
{ id: "links", icon: "link", label: "Ссылки" }, { id: "links", icon: "link", label: "Ссылки" },
{ id: "stories", icon: "play-story", label: "Сторис" },
{ id: "calendar", icon: "calendar", label: "Календарь" }, { id: "calendar", icon: "calendar", label: "Календарь" },
]; ];
@@ -114,6 +116,8 @@
<SharedMedia {chatId} kinds={FILE_KINDS} layout="list" /> <SharedMedia {chatId} kinds={FILE_KINDS} layout="list" />
{:else if tab === "links"} {:else if tab === "links"}
<SharedLinks {chatId} /> <SharedLinks {chatId} />
{:else if tab === "stories"}
<StoriesArchive {chatId} />
{:else if tab === "calendar"} {:else if tab === "calendar"}
<ChatCalendar {chatId} /> <ChatCalendar {chatId} />
{/if} {/if}
@@ -1,16 +1,29 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { downloadMedia } from "$lib/api/download";
import { getChatMedia } from "$lib/api/endpoints"; 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 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 EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime"; import { formatListDate } from "$lib/format/datetime";
import { formatBytes, mediaKindLabel } from "$lib/format/media"; import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte"; 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 { ui } from "$lib/stores/ui.svelte";
import { isMobile } from "$lib/viewport";
interface Props { interface Props {
chatId: number; chatId: number;
@@ -25,9 +38,39 @@
let items = $state<MediaView[]>([]); let items = $state<MediaView[]>([]);
let loading = $state(false); let loading = $state(false);
let done = $state(false); let done = $state(false);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const previews = $state<Record<number, InlineMedia>>({}); const previews = $state<Record<number, InlineMedia>>({});
let token = 0; 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() { async function loadMore() {
if (loading || done) { if (loading || done) {
return; return;
@@ -92,6 +135,7 @@
kind: item.kind, kind: item.kind,
downloaded: item.downloaded, downloaded: item.downloaded,
mime: item.mime, mime: item.mime,
file_name: item.file_name,
file_size: item.file_size, file_size: item.file_size,
ttl_seconds: item.ttl_seconds, ttl_seconds: item.ttl_seconds,
duration: null, 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); ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`); goto(`/app/${chatId}`);
if (isMobile()) {
ui.closePanel();
}
} }
function preview(item: MediaView): InlineMedia | undefined { function preview(item: MediaView): InlineMedia | undefined {
@@ -127,12 +179,29 @@
{/if} {/if}
{:else if layout === "grid"} {:else if layout === "grid"}
<div class="grid"> <div class="grid">
{#each items as item (item.id)} {#each items as item, index (item.id)}
<button type="button" class="tile" onclick={() => open(item.message_id)}> <ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="tile"
tabindex="0"
onclick={() => open(index)}
>
{#if preview(item)?.state === "ready"} {#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"} {#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> <span class="play"><Icon name="play" size="1.5rem" /></span>
{:else} {:else}
<img src={ready.url} alt=""> <img src={ready.url} alt="">
@@ -143,16 +212,41 @@
> >
{/if} {/if}
</button> </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} {/each}
</div> </div>
{:else} {:else}
<ul class="list"> <ul class="list">
{#each items as item (item.id)} {#each items as item, index (item.id)}
<li> <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="file-icon"><Icon name="document" /></span>
<span class="meta"> <span class="meta">
<span class="name">{mediaKindLabel(item.kind)}</span> <span class="name">{displayName(item)}</span>
<span class="sub"> <span class="sub">
{formatListDate(item.created_at)} {formatListDate(item.created_at)}
{#if item.file_size} {#if item.file_size}
@@ -161,6 +255,24 @@
</span> </span>
</span> </span>
</button> </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> </li>
{/each} {/each}
</ul> </ul>
@@ -177,6 +289,13 @@
</button> </button>
{/if} {/if}
<MediaViewer
bind:open={viewerOpen}
bind:index={viewerIndex}
{chatId}
items={viewerItems}
/>
<style lang="scss"> <style lang="scss">
.center { .center {
display: flex; display: flex;
@@ -19,6 +19,7 @@
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
$effect(() => { $effect(() => {
chats.ensure(hit.chat_id);
const ids: number[] = []; const ids: number[] = [];
if (hit.chat_id > 0) { if (hit.chat_id > 0) {
ids.push(hit.chat_id); ids.push(hit.chat_id);
@@ -52,6 +52,11 @@
label="Сторис" label="Сторис"
onclick={() => ui.openPanel("stories-all")} onclick={() => ui.openPanel("stories-all")}
/> />
<SettingsItem
icon="allow-share"
label="Файлы по ссылке"
onclick={() => ui.openPanel("shares")}
/>
<SettingsItem <SettingsItem
icon="eye" icon="eye"
label="Отслеживания" 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"> <script lang="ts">
import { untrack } from "svelte"; 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 { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types"; import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte"; import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Avatar from "$lib/components/ui/Avatar.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 EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { peerName } from "$lib/format/peer"; import { peerName } from "$lib/format/peer";
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
interface Group { interface Group {
hasAvatar: boolean; hasAvatar: boolean;
@@ -25,18 +33,20 @@
let groups = $state<Group[]>([]); let groups = $state<Group[]>([]);
let loading = $state(false); let loading = $state(false);
const previews = $state<Record<number, string | null>>({}); const previews = $state<Record<number, string | null>>({});
const expanded = $state<Record<number, boolean>>({});
let token = 0; let token = 0;
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
let viewerItems = $state<StoryView[]>([]); let viewerItems = $state<StoryView[]>([]);
let viewerPeerId = $state(0); let viewerPeerId = $state(0);
let backfilling = $state<number | null>(null);
async function load() { async function load() {
const current = token; const current = token;
loading = true; loading = true;
try { try {
const stories = await getStories(0, { limit: FETCH_LIMIT }); const stories = await getStories(null, { limit: FETCH_LIMIT });
if (current !== token) { if (current !== token) {
return; return;
} }
@@ -50,11 +60,20 @@
} }
} }
const peerIds = [...byPeer.keys()].filter((id) => id > 0); 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) { if (current !== token) {
return; return;
} }
const peerById = new Map(peers.map((peer) => [peer.peer_id, peer])); 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]) => { groups = [...byPeer.entries()].map(([peerId, stories]) => {
if (peerId > 0) { if (peerId > 0) {
const peer = peerById.get(peerId) ?? null; const peer = peerById.get(peerId) ?? null;
@@ -66,7 +85,7 @@
stories, stories,
}; };
} }
const chat = chats.byId(peerId); const chat = chatById.get(peerId);
return { return {
peerId, peerId,
kind: "chat" as const, kind: "chat" as const,
@@ -93,12 +112,17 @@
for (const key of Object.keys(previews)) { for (const key of Object.keys(previews)) {
delete previews[Number(key)]; delete previews[Number(key)];
} }
for (const key of Object.keys(expanded)) {
delete expanded[Number(key)];
}
load().catch(() => undefined); load().catch(() => undefined);
}); });
}); });
$effect(() => { $effect(() => {
const list = groups.flatMap((group) => group.stories); const list = groups
.filter((group) => expanded[group.peerId])
.flatMap((group) => group.stories);
let active = true; let active = true;
untrack(() => { untrack(() => {
for (const item of list) { 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) { function openViewer(group: Group, index: number) {
viewerItems = group.stories; viewerItems = group.stories;
viewerPeerId = group.peerId; viewerPeerId = group.peerId;
viewerIndex = index; viewerIndex = index;
viewerOpen = true; viewerOpen = true;
} }
function toggle(peerId: number) {
expanded[peerId] = !expanded[peerId];
}
</script> </script>
{#if groups.length === 0} {#if groups.length === 0}
@@ -136,6 +179,12 @@
{#each groups as group (group.peerId)} {#each groups as group (group.peerId)}
<section class="group"> <section class="group">
<header class="group-head"> <header class="group-head">
<button
type="button"
class="group-toggle"
aria-expanded={Boolean(expanded[group.peerId])}
onclick={() => toggle(group.peerId)}
>
<Avatar <Avatar
name={group.name} name={group.name}
colorKey={group.peerId} colorKey={group.peerId}
@@ -145,37 +194,32 @@
/> />
<span class="group-name">{group.name}</span> <span class="group-name">{group.name}</span>
<span class="group-count">{group.stories.length}</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> </header>
{#if expanded[group.peerId]}
<div class="grid"> <div class="grid">
{#each group.stories as item, index (item.story_id)} {#each group.stories as item, index (item.story_id)}
<button <StoryTile
type="button" story={item}
class="tile" preview={previews[item.story_id]}
class:expired={item.deleted} onopen={() => openViewer(group, index)}
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>
{/each} {/each}
</div> </div>
{/if}
</section> </section>
{/each} {/each}
@@ -205,6 +249,31 @@
padding: 0.625rem 0.75rem; 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 { .group-name {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
@@ -1,21 +1,33 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { page } from "$app/state"; 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 { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types"; import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.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 EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte"; 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 PAGE = 60;
const peerId = $derived( 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 items = $state<StoryView[]>([]);
let loading = $state(false); let loading = $state(false);
let done = $state(false); let done = $state(false);
@@ -89,50 +101,72 @@
viewerIndex = index; viewerIndex = index;
viewerOpen = true; 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> </script>
{#if peerId === null} {#if peerId === null}
<EmptyState title="Сторис" description="Откройте чат" /> <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} {#if loading}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState title="Нет сторис" /> <EmptyState
title="Нет сторис"
description="Нажмите «Загрузить старые», чтобы забрать архив"
/>
{/if} {/if}
{:else} {:else}
<div class="grid"> <div class="grid">
{#each items as item, index (item.story_id)} {#each items as item, index (item.story_id)}
<button <StoryTile
type="button" story={item}
class="tile" preview={previews[item.story_id]}
class:expired={item.deleted} onopen={() => openViewer(index)}
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>
{/each} {/each}
</div> </div>
@@ -147,7 +181,6 @@
</button> </button>
{/if} {/if}
{#if peerId !== null}
<StoryViewer <StoryViewer
{peerId} {peerId}
{items} {items}
@@ -164,6 +197,14 @@
padding: 2rem 0; 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 { .grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); 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"> <script lang="ts">
import { Dialog } from "bits-ui"; import { Dialog } from "bits-ui";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { downloadStory } from "$lib/api/download";
import { loadStoryMedia } from "$lib/api/stories"; import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types"; 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 Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime"; import { formatFull } from "$lib/format/datetime";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props { interface Props {
index: number; index: number;
@@ -22,16 +27,23 @@
}: Props = $props(); }: Props = $props();
const PHOTO_SECONDS = 6; const PHOTO_SECONDS = 6;
const HOLD_MS = 180;
let url = $state<string | null>(null); let url = $state<string | null>(null);
let loading = $state(false); let loading = $state(false);
let ready = $state(false); let ready = $state(false);
let videoProgress = $state(0); let videoProgress = $state(0);
let muted = $state(true); let muted = $state(true);
let held = $state(false);
let saving = $state(false);
let video = $state<HTMLVideoElement | null>(null);
let token = 0; let token = 0;
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let suppressTap = false;
const story = $derived(items[index] ?? null); const story = $derived(items[index] ?? null);
const isVideo = $derived(story?.media_kind === "video"); const isVideo = $derived(story?.media_kind === "video");
const canSave = $derived(Boolean(story?.downloaded));
function step(delta: number) { function step(delta: number) {
const next = index + delta; const next = index + delta;
@@ -49,6 +61,37 @@
step(1); 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) { async function load(item: StoryView) {
loading = true; loading = true;
ready = false; ready = false;
@@ -63,12 +106,36 @@
} }
function onVideoTime(event: Event) { function onVideoTime(event: Event) {
const video = event.currentTarget as HTMLVideoElement; const element = event.currentTarget as HTMLVideoElement;
if (video.duration > 0) { if (element.duration > 0) {
videoProgress = video.currentTime / video.duration; 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) { function onkeydown(event: KeyboardEvent) {
if (!open) { if (!open) {
return; return;
@@ -77,6 +144,14 @@
step(-1); step(-1);
} else if (event.key === "ArrowRight") { } else if (event.key === "ArrowRight") {
step(1); 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(() => { untrack(() => {
url = null; url = null;
ready = false; ready = false;
held = false;
}); });
} }
}); });
</script> </script>
<svelte:window {onkeydown} /> <svelte:window {onkeydown} onpointerup={releaseHold} />
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="story-overlay" /> <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> <Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
<div class="bars"> <div class="bars">
{#each items as item, i (item.story_id)} {#each items as item, i (item.story_id)}
@@ -119,6 +195,7 @@
{#if ready && !isVideo} {#if ready && !isVideo}
<div <div
class="fill anim" class="fill anim"
class:paused={held}
style="animation-duration: {PHOTO_SECONDS}s" style="animation-duration: {PHOTO_SECONDS}s"
onanimationend={advance} onanimationend={advance}
></div> ></div>
@@ -146,6 +223,29 @@
<Icon name={muted ? "speaker-muted-story" : "speaker-story"} /> <Icon name={muted ? "speaker-muted-story" : "speaker-story"} />
</button> </button>
{/if} {/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="Закрыть"> <Dialog.Close class="round" aria-label="Закрыть">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
@@ -158,6 +258,7 @@
{:else if url && isVideo} {:else if url && isVideo}
<!-- biome-ignore lint/a11y/useMediaCaption: archived story has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived story has no captions -->
<video <video
bind:this={video}
class="media" class="media"
src={url} src={url}
autoplay autoplay
@@ -200,14 +301,22 @@
type="button" type="button"
class="tap prev" class="tap prev"
aria-label="Назад" aria-label="Назад"
onclick={() => step(-1)} onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(-1)}
></button> ></button>
<button <button
type="button" type="button"
class="tap next" class="tap next"
aria-label="Вперёд" aria-label="Вперёд"
onclick={() => step(1)} onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(1)}
></button> ></button>
{#if held}
<span class="hold-hint">Пауза</span>
{/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
@@ -268,6 +377,10 @@
&.anim { &.anim {
animation: story-progress linear forwards; animation: story-progress linear forwards;
} }
&.paused {
animation-play-state: paused;
}
} }
@keyframes story-progress { @keyframes story-progress {
@@ -376,6 +489,9 @@
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
touch-action: none;
user-select: none;
-webkit-touch-callout: none;
&.prev { &.prev {
left: 0; left: 0;
@@ -386,4 +502,44 @@
width: 65%; 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> </style>
@@ -1,3 +1,9 @@
<script lang="ts" module>
type PointerHandler = (event: PointerEvent) => void;
const claimed = new WeakSet<Event>();
</script>
<script lang="ts"> <script lang="ts">
import { ContextMenu } from "bits-ui"; import { ContextMenu } from "bits-ui";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
@@ -8,12 +14,42 @@
} }
const { children, menu }: Props = $props(); 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> </script>
<ContextMenu.Root> <ContextMenu.Root bind:open>
<ContextMenu.Trigger> <ContextMenu.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
{@render children({ props })} {@render children({ props: triggerProps(props) })}
{/snippet} {/snippet}
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal>
+15 -15
View File
@@ -1,27 +1,27 @@
const MEDIA_KIND_LABELS: Record<string, string> = { const MEDIA_KIND_LABELS: Record<string, string> = {
photo: "Photo", photo: "Фото",
video: "Video", video: "Видео",
animation: "GIF", animation: "GIF",
gif: "GIF", gif: "GIF",
voice: "Voice message", voice: "Голосовое",
audio: "Audio", audio: "Аудио",
video_note: "Video message", video_note: "Кружок",
sticker: "Sticker", sticker: "Стикер",
document: "File", document: "Файл",
contact: "Contact", contact: "Контакт",
location: "Location", location: "Геопозиция",
venue: "Location", venue: "Геопозиция",
poll: "Poll", poll: "Опрос",
dice: "Dice", dice: "Кубик",
game: "Game", game: "Игра",
story: "Story", story: "Сторис",
}; };
export function mediaKindLabel(kind: string | null): string | null { export function mediaKindLabel(kind: string | null): string | null {
if (!kind) { if (!kind) {
return null; return null;
} }
return MEDIA_KIND_LABELS[kind] ?? "Media"; return MEDIA_KIND_LABELS[kind] ?? "Медиа";
} }
const BYTE_UNITS = ["B", "KB", "MB", "GB"]; const BYTE_UNITS = ["B", "KB", "MB", "GB"];
+18 -2
View File
@@ -1,10 +1,22 @@
import type { PresenceSample } from "$lib/api/types"; import type { PresenceSample } from "$lib/api/types";
import { formatListDate } from "$lib/format/datetime"; import { formatListDate } from "$lib/format/datetime";
export function isOnline(sample: PresenceSample | null): boolean {
if (sample === null || sample.status !== "online") {
return false;
}
if (sample.next_offline_date === null) {
return false;
}
return new Date(sample.next_offline_date).getTime() > Date.now();
}
export function formatPresence(sample: PresenceSample): string { export function formatPresence(sample: PresenceSample): string {
switch (sample.status) { switch (sample.status) {
case "online": case "online":
return "online"; return isOnline(sample)
? "online"
: lastSeen(sample.last_online_date ?? sample.ts);
case "recently": case "recently":
return "last seen recently"; return "last seen recently";
case "last_week": case "last_week":
@@ -15,7 +27,11 @@ export function formatPresence(sample: PresenceSample): string {
return "last seen a long time ago"; return "last seen a long time ago";
default: default:
return sample.last_online_date return sample.last_online_date
? `last seen ${formatListDate(sample.last_online_date)}` ? lastSeen(sample.last_online_date)
: "offline"; : "offline";
} }
} }
function lastSeen(date: string): string {
return `last seen ${formatListDate(date)}`;
}
+55
View File
@@ -0,0 +1,55 @@
import type { FileShare } from "$lib/api/types";
import { formatFull } from "$lib/format/datetime";
const TEEN_START = 11;
const TEEN_END = 14;
const FEW_END = 4;
export function plural(
count: number,
one: string,
few: string,
many: string
): string {
const tail = count % 100;
if (tail >= TEEN_START && tail <= TEEN_END) {
return many;
}
const last = count % 10;
if (last === 1) {
return one;
}
if (last >= 2 && last <= FEW_END) {
return few;
}
return many;
}
export function formatDownloads(share: FileShare): string {
const word = plural(
share.download_count,
"скачивание",
"скачивания",
"скачиваний"
);
if (share.max_downloads === null) {
return `${share.download_count} ${word}`;
}
return `${share.download_count} из ${share.max_downloads}`;
}
export function formatExpiry(share: FileShare): string {
if (share.revoked_at) {
return "отозвана";
}
if (!share.expires_at) {
return "бессрочно";
}
const expires = Date.parse(share.expires_at);
const prefix = expires <= Date.now() ? "истекла" : "до";
return `${prefix} ${formatFull(share.expires_at)}`;
}
export function formatShareLimits(share: FileShare): string {
return `${formatDownloads(share)} · ${formatExpiry(share)}`;
}
+20
View File
@@ -0,0 +1,20 @@
export const POSTER_TIME = 0.001;
export function poster(node: HTMLVideoElement) {
const seek = () => {
if (node.currentTime === 0) {
node.currentTime = POSTER_TIME;
}
};
node.addEventListener("loadedmetadata", seek);
if (node.readyState >= node.HAVE_METADATA) {
seek();
}
return {
destroy() {
node.removeEventListener("loadedmetadata", seek);
},
};
}
@@ -0,0 +1,42 @@
import { listChats } from "$lib/api/endpoints";
import type { Chat } from "$lib/api/types";
const DEBOUNCE_MS = 250;
const LIMIT = 40;
export function createChatPicker() {
let results = $state<Chat[]>([]);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
async function run(query: string, current: number) {
try {
const found = await listChats({
limit: LIMIT,
search: query || undefined,
});
if (current === seq) {
results = found;
}
} catch {
if (current === seq) {
results = [];
}
}
}
return {
get results(): Chat[] {
return results;
},
search(query: string) {
const current = ++seq;
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
run(query.trim(), current).catch(() => undefined);
}, DEBOUNCE_MS);
},
};
}
+182 -76
View File
@@ -1,104 +1,178 @@
import { enrichChat, getJob, listChats } from "$lib/api/endpoints"; import { enrichChat, getChat, getJob, listChats } from "$lib/api/endpoints";
import type { Chat, LiveEvent } from "$lib/api/types"; import type { Chat, LiveEvent } from "$lib/api/types";
import { folderContains } from "$lib/format/folders";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { events } from "$lib/stores/events.svelte"; import { events } from "$lib/stores/events.svelte";
import { peers } from "$lib/stores/peers.svelte"; import { folders } from "$lib/stores/folders.svelte";
const POLL_INTERVAL = 1500; const POLL_INTERVAL = 1500;
const POLL_MAX = 12; const POLL_MAX = 12;
const PAGE_SIZE = 200; const PAGE_SIZE = 40;
const ALL = "all";
const EMPTY: Chat[] = [];
interface Bucket {
hasMore: boolean;
list: Chat[];
loaded: boolean;
loading: boolean;
}
function createBucket(): Bucket {
return { list: [], loaded: false, loading: false, hasMore: true };
}
function bucketKey(folderId: number | null): string {
return folderId === null ? ALL : String(folderId);
}
function createChats() { function createChats() {
let list = $state<Chat[]>([]); let buckets = $state<Record<string, Bucket>>({});
let loaded = $state(false); let extra = $state<Record<number, Chat>>({});
let loading = $state(false);
let hasMore = $state(false);
let revision = $state(0);
let account: number | null = null; let account: number | null = null;
let filling = false;
const enriched = new Set<number>(); const enriched = new Set<number>();
const resolving = new Set<number>();
function syncAccount() { function syncAccount() {
if (accounts.selectedId !== account) { if (accounts.selectedId !== account) {
account = accounts.selectedId; account = accounts.selectedId;
list = []; buckets = {};
loaded = false; extra = {};
enriched.clear(); enriched.clear();
resolving.clear();
} }
} }
async function load(force: boolean) { function activeKey(): string {
syncAccount(); return bucketKey(folders.selectedId);
if (account === null || (loaded && !force)) {
return;
}
loading = true;
try {
const page = await listChats({ limit: PAGE_SIZE });
list = page;
hasMore = page.length === PAGE_SIZE;
loaded = true;
} finally {
loading = false;
}
loadAll();
} }
async function loadAll() { function active(): Bucket | undefined {
if (filling) { return buckets[activeKey()];
return;
} }
filling = true;
try { function folderQuery(key: string): number | undefined {
while (hasMore) { return key === ALL ? undefined : Number(key);
if (loading) { }
await new Promise((resolve) => {
setTimeout(resolve, 50); async function fetchPage(key: string, offset: number): Promise<Chat[]> {
return await listChats({
limit: PAGE_SIZE,
offset,
folder_id: folderQuery(key),
}); });
}
async function load(folderId: number | null, force: boolean) {
syncAccount();
const key = bucketKey(folderId);
buckets[key] ??= createBucket();
const bucket = buckets[key];
if (account === null || bucket.loading || (bucket.loaded && !force)) {
return;
}
bucket.loading = true;
try {
const page = await fetchPage(key, 0);
bucket.list = page;
bucket.hasMore = page.length === PAGE_SIZE;
bucket.loaded = true;
} finally {
bucket.loading = false;
}
}
async function loadMore(folderId: number | null) {
syncAccount();
const key = bucketKey(folderId);
const bucket = buckets[key];
if (
account === null ||
bucket === undefined ||
bucket.loading ||
!(bucket.loaded && bucket.hasMore)
) {
return;
}
bucket.loading = true;
try {
const page = await fetchPage(key, bucket.list.length);
const seen = new Set(bucket.list.map((chat) => chat.chat_id));
bucket.list = [
...bucket.list,
...page.filter((chat) => !seen.has(chat.chat_id)),
];
bucket.hasMore = page.length === PAGE_SIZE;
} finally {
bucket.loading = false;
}
}
function bucketsFor(chat: Chat): Bucket[] {
const matching: Bucket[] = [];
for (const [key, bucket] of Object.entries(buckets)) {
if (key === ALL) {
matching.push(bucket);
continue; continue;
} }
const before = list.length; const folder = folders.list.find(
await loadMore(); (candidate) => candidate.folder_id === Number(key)
if (list.length === before) { );
break; if (folder && folderContains(folder, chat)) {
matching.push(bucket);
} }
} }
} finally { return matching;
filling = false;
}
} }
async function loadMore() { function known(id: number): boolean {
syncAccount(); if (extra[id] !== undefined) {
if (account === null || loading || !loaded || !hasMore) { return true;
}
return Object.values(buckets).some((bucket) =>
bucket.list.some((chat) => chat.chat_id === id)
);
}
function hoist(bucket: Bucket, chat: Chat) {
bucket.list = [chat, ...bucket.list.filter((item) => item !== chat)];
}
async function insertUnknown(chatId: number) {
const chat = await getChat(chatId);
if (chat === null) {
return; return;
} }
loading = true; for (const bucket of bucketsFor(chat)) {
try { if (!bucket.list.some((item) => item.chat_id === chatId)) {
const page = await listChats({ limit: PAGE_SIZE, offset: list.length }); bucket.list = [chat, ...bucket.list];
const seen = new Set(list.map((chat) => chat.chat_id)); }
list = [...list, ...page.filter((chat) => !seen.has(chat.chat_id))];
hasMore = page.length === PAGE_SIZE;
} finally {
loading = false;
} }
} }
function applyEvent(event: LiveEvent) { function applyEvent(event: LiveEvent) {
if (event.type !== "message" || !loaded) { if (event.type !== "message") {
return; return;
} }
const message = event.message; const message = event.message;
const existing = list.find((chat) => chat.chat_id === message.chat_id); let known = false;
if (!existing) { for (const bucket of Object.values(buckets)) {
load(true); const existing = bucket.list.find(
return; (chat) => chat.chat_id === message.chat_id
);
if (existing === undefined) {
continue;
} }
known = true;
existing.last_date = message.date; existing.last_date = message.date;
existing.last_sender_id = message.sender_id; existing.last_sender_id = message.sender_id;
existing.last_text = message.text; existing.last_text = message.text;
existing.message_count++; existing.message_count++;
list = [existing, ...list.filter((chat) => chat !== existing)]; hoist(bucket, existing);
}
if (!known && buckets[ALL]?.loaded) {
insertUnknown(message.chat_id).catch(() => undefined);
}
} }
async function waitForJob(jobId: number) { async function waitForJob(jobId: number) {
@@ -113,38 +187,69 @@ function createChats() {
} }
} }
function replaceEverywhere(chat: Chat) {
for (const bucket of Object.values(buckets)) {
const index = bucket.list.findIndex(
(item) => item.chat_id === chat.chat_id
);
if (index !== -1) {
bucket.list[index] = chat;
}
}
if (extra[chat.chat_id] !== undefined) {
extra[chat.chat_id] = chat;
}
}
events.subscribe(applyEvent); events.subscribe(applyEvent);
events.onReconnect(() => { events.onReconnect(() => {
if (loaded) { if (active()?.loaded) {
load(true); load(folders.selectedId, true);
} }
}); });
return { return {
get list(): Chat[] { get list(): Chat[] {
return list; return active()?.list ?? EMPTY;
}, },
get loaded(): boolean { get loaded(): boolean {
return loaded; return active()?.loaded ?? false;
}, },
get loading(): boolean { get loading(): boolean {
return loading; return active()?.loading ?? false;
}, },
get hasMore(): boolean { get hasMore(): boolean {
return hasMore; return active()?.hasMore ?? false;
},
get revision(): number {
return revision;
}, },
loadMore, loadMore,
byId(id: number): Chat | undefined { byId(id: number): Chat | undefined {
return list.find((chat) => chat.chat_id === id); for (const bucket of Object.values(buckets)) {
const found = bucket.list.find((chat) => chat.chat_id === id);
if (found !== undefined) {
return found;
}
}
return extra[id];
}, },
load() { ensure(id: number) {
return load(false); syncAccount();
if (account === null || resolving.has(id) || known(id)) {
return;
}
resolving.add(id);
getChat(id)
.then((chat) => {
if (chat !== null) {
extra[id] = chat;
}
})
.catch(() => resolving.delete(id));
}, },
refresh() { load(folderId: number | null = folders.selectedId) {
return load(true); return load(folderId, false);
},
refresh(folderId: number | null = folders.selectedId) {
return load(folderId, true);
}, },
async enrich(chatId: number) { async enrich(chatId: number) {
syncAccount(); syncAccount();
@@ -155,9 +260,10 @@ function createChats() {
try { try {
const { job_id } = await enrichChat(chatId); const { job_id } = await enrichChat(chatId);
await waitForJob(job_id); await waitForJob(job_id);
peers.reset(); const chat = await getChat(chatId);
await load(true); if (chat !== null) {
revision++; replaceEverywhere(chat);
}
} catch { } catch {
enriched.delete(chatId); enriched.delete(chatId);
} }
+48 -10
View File
@@ -1,11 +1,15 @@
import { browser } from "$app/environment";
import type { LiveEvent } from "$lib/api/types"; import type { LiveEvent } from "$lib/api/types";
import { auth } from "$lib/stores/auth.svelte"; import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api"; const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const RECONNECT_DELAY = 2000; const RECONNECT_DELAY = 2000;
const STALL_TIMEOUT = 45_000;
type Listener = (event: LiveEvent) => void; type Listener = (event: LiveEvent) => void;
const STALLED = Symbol("stalled");
function parseFrame(block: string): LiveEvent | null { function parseFrame(block: string): LiveEvent | null {
for (const line of block.split("\n")) { for (const line of block.split("\n")) {
if (line.startsWith("data:")) { if (line.startsWith("data:")) {
@@ -25,6 +29,7 @@ function createEvents() {
let epoch = $state(0); let epoch = $state(0);
let account: number | null = null; let account: number | null = null;
let controller: AbortController | null = null; let controller: AbortController | null = null;
let lastFrameAt = 0;
function emit(event: LiveEvent) { function emit(event: LiveEvent) {
for (const listener of listeners) { for (const listener of listeners) {
@@ -49,11 +54,24 @@ function createEvents() {
return rest; return rest;
} }
function readWithTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>
): Promise<ReadableStreamReadResult<Uint8Array> | typeof STALLED> {
let timer: ReturnType<typeof setTimeout>;
const stall = new Promise<typeof STALLED>((resolve) => {
timer = setTimeout(() => resolve(STALLED), STALL_TIMEOUT);
});
return Promise.race([reader.read(), stall]).finally(() => {
clearTimeout(timer);
});
}
async function consume(response: Response, signal: AbortSignal) { async function consume(response: Response, signal: AbortSignal) {
if (!response.body) { if (!response.body) {
return; return;
} }
epoch++; epoch++;
lastFrameAt = Date.now();
for (const listener of reconnectListeners) { for (const listener of reconnectListeners) {
listener(); listener();
} }
@@ -61,11 +79,16 @@ function createEvents() {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
while (!signal.aborted) { while (!signal.aborted) {
const { value, done } = await reader.read(); const result = await readWithTimeout(reader);
if (done) { if (result === STALLED) {
await reader.cancel().catch(() => undefined);
return; return;
} }
buffer = drain(buffer + decoder.decode(value, { stream: true })); if (result.done) {
return;
}
lastFrameAt = Date.now();
buffer = drain(buffer + decoder.decode(result.value, { stream: true }));
} }
} }
@@ -98,6 +121,22 @@ function createEvents() {
controller = null; controller = null;
} }
function start(accountId: number) {
close();
account = accountId;
controller = new AbortController();
run(accountId, controller.signal);
}
if (browser) {
document.addEventListener("visibilitychange", () => {
const stale = Date.now() - lastFrameAt > STALL_TIMEOUT;
if (document.visibilityState === "visible" && account !== null && stale) {
start(account);
}
});
}
return { return {
get epoch(): number { get epoch(): number {
return epoch; return epoch;
@@ -111,16 +150,15 @@ function createEvents() {
return () => reconnectListeners.delete(listener); return () => reconnectListeners.delete(listener);
}, },
open(accountId: number | null) { open(accountId: number | null) {
if (accountId === account) {
return;
}
close();
account = accountId;
if (accountId === null || !auth.token) { if (accountId === null || !auth.token) {
close();
account = null;
return; return;
} }
controller = new AbortController(); if (accountId === account && controller !== null) {
run(accountId, controller.signal); return;
}
start(accountId);
}, },
}; };
} }
+20 -12
View File
@@ -1,30 +1,22 @@
import { discoverPeers, searchMessages } from "$lib/api/endpoints"; import { discoverPeers, listChats, searchMessages } from "$lib/api/endpoints";
import type { DiscoverItem, SearchHit } from "$lib/api/types"; import type { Chat, DiscoverItem, SearchHit } from "$lib/api/types";
import { chats } from "$lib/stores/chats.svelte";
const DEBOUNCE_MS = 250; const DEBOUNCE_MS = 250;
const REMOTE_DEBOUNCE_MS = 700; const REMOTE_DEBOUNCE_MS = 700;
const MIN_LENGTH = 1; const MIN_LENGTH = 1;
const CHAT_LIMIT = 30;
function createSearch() { function createSearch() {
let active = $state(false); let active = $state(false);
let query = $state(""); let query = $state("");
let messageHits = $state<SearchHit[]>([]); let messageHits = $state<SearchHit[]>([]);
let chatHits = $state<Chat[]>([]);
let peerResults = $state<DiscoverItem[]>([]); let peerResults = $state<DiscoverItem[]>([]);
let loading = $state(false); let loading = $state(false);
let timers: ReturnType<typeof setTimeout>[] = []; let timers: ReturnType<typeof setTimeout>[] = [];
let seq = 0; let seq = 0;
const trimmed = $derived(query.trim()); const trimmed = $derived(query.trim());
const chatHits = $derived.by(() => {
const needle = trimmed.toLowerCase();
if (needle.length < MIN_LENGTH) {
return [];
}
return chats.list.filter((chat) =>
(chat.title ?? "").toLowerCase().includes(needle)
);
});
const peerHits = $derived.by(() => { const peerHits = $derived.by(() => {
const shown = new Set(chatHits.map((chat) => chat.chat_id)); const shown = new Set(chatHits.map((chat) => chat.chat_id));
return peerResults.filter((item) => !shown.has(item.chat_id)); return peerResults.filter((item) => !shown.has(item.chat_id));
@@ -47,6 +39,19 @@ function createSearch() {
} }
} }
async function runChats(value: string, current: number) {
try {
const found = await listChats({ limit: CHAT_LIMIT, search: value });
if (current === seq) {
chatHits = found;
}
} catch {
if (current === seq) {
chatHits = [];
}
}
}
async function runPeers(value: string, current: number, remote: boolean) { async function runPeers(value: string, current: number, remote: boolean) {
try { try {
const items = await discoverPeers(value, remote); const items = await discoverPeers(value, remote);
@@ -73,6 +78,7 @@ function createSearch() {
const current = ++seq; const current = ++seq;
if (value.length < MIN_LENGTH) { if (value.length < MIN_LENGTH) {
messageHits = []; messageHits = [];
chatHits = [];
peerResults = []; peerResults = [];
loading = false; loading = false;
return; return;
@@ -81,6 +87,7 @@ function createSearch() {
timers.push( timers.push(
setTimeout(() => { setTimeout(() => {
runMessages(value, current).catch(() => undefined); runMessages(value, current).catch(() => undefined);
runChats(value, current).catch(() => undefined);
runPeers(value, current, false).catch(() => undefined); runPeers(value, current, false).catch(() => undefined);
}, DEBOUNCE_MS), }, DEBOUNCE_MS),
setTimeout(() => { setTimeout(() => {
@@ -122,6 +129,7 @@ function createSearch() {
active = false; active = false;
query = ""; query = "";
messageHits = []; messageHits = [];
chatHits = [];
peerResults = []; peerResults = [];
loading = false; loading = false;
seq++; seq++;
+36
View File
@@ -0,0 +1,36 @@
import type { ShareSubject } from "$lib/api/types";
function createShareUi() {
let open = $state(false);
let subject = $state<ShareSubject | null>(null);
let label = $state("файл");
let revision = $state(0);
return {
get open() {
return open;
},
set open(value: boolean) {
open = value;
},
get subject() {
return subject;
},
get label() {
return label;
},
get revision() {
return revision;
},
share(next: ShareSubject, name = "файл") {
subject = next;
label = name;
open = true;
},
touch() {
revision += 1;
},
};
}
export const shareUi = createShareUi();
+2 -1
View File
@@ -12,7 +12,8 @@ export type RightPanel =
| "stories-all" | "stories-all"
| "policy" | "policy"
| "watches" | "watches"
| "alerts"; | "alerts"
| "shares";
export type LeftView = "main" | "settings"; export type LeftView = "main" | "settings";
+8
View File
@@ -153,6 +153,14 @@ html.theme-transition * {
animation: ripple-animation 700ms; animation: ripple-animation 700ms;
} }
@media (pointer: coarse) {
[data-context-menu-trigger] {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
}
.bg-menu-content { .bg-menu-content {
z-index: var(--z-portal-menu); z-index: var(--z-portal-menu);
min-width: 12rem; min-width: 12rem;
+1 -1
View File
@@ -140,7 +140,7 @@
--color-links: #{$color-links}; --color-links: #{$color-links};
--color-own-links: #{$color-white}; --color-own-links: #{$color-links};
--color-placeholders: #{$color-placeholders}; --color-placeholders: #{$color-placeholders};
+7
View File
@@ -0,0 +1,7 @@
import { browser } from "$app/environment";
const MOBILE_QUERY = "(max-width: 600px)";
export function isMobile(): boolean {
return browser && window.matchMedia(MOBILE_QUERY).matches;
}
+11 -1
View File
@@ -8,11 +8,14 @@
import SearchInput from "$lib/components/search/SearchInput.svelte"; import SearchInput from "$lib/components/search/SearchInput.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte"; import SearchResults from "$lib/components/search/SearchResults.svelte";
import Settings from "$lib/components/settings/Settings.svelte"; import Settings from "$lib/components/settings/Settings.svelte";
import ShareDialog from "$lib/components/shares/ShareDialog.svelte";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
import { events } from "$lib/stores/events.svelte"; import { events } from "$lib/stores/events.svelte";
import { search } from "$lib/stores/search.svelte"; import { search } from "$lib/stores/search.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte"; import { ui } from "$lib/stores/ui.svelte";
@@ -27,7 +30,7 @@
}); });
$effect(() => { $effect(() => {
events.open(accounts.selectedId); events.open(auth.token === null ? null : accounts.selectedId);
}); });
</script> </script>
@@ -97,6 +100,13 @@
{/if} {/if}
</div> </div>
<ShareDialog
bind:open={shareUi.open}
subject={shareUi.subject}
label={shareUi.label}
onchange={() => shareUi.touch()}
/>
<style lang="scss"> <style lang="scss">
#Main { #Main {
display: grid; display: grid;
@@ -11,6 +11,7 @@
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
} }
chats.ensure(chatId);
chats.enrich(chatId); chats.enrich(chatId);
}); });
</script> </script>
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
proxy: { proxy: {
"/api": { target: proxyTarget, changeOrigin: true }, "/api": { target: proxyTarget, changeOrigin: true },
"/mcp": { target: proxyTarget, changeOrigin: true }, "/mcp": { target: proxyTarget, changeOrigin: true },
"/f": { target: proxyTarget, changeOrigin: true },
}, },
}, },
}); });