Compare commits

..
10 Commits
55 changed files with 3891 additions and 273 deletions
@@ -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")
+4
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,6 +30,7 @@ from api.routers import (
presence, presence,
profile, profile,
search, search,
shares,
social, social,
stories, stories,
watches, watches,
@@ -88,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)
+15
View File
@@ -19,6 +19,11 @@ class BackfillRequest(BaseModel):
full: bool = False full: bool = False
class StoriesBackfillRequest(BaseModel):
account_id: int
peer_id: int
class FetchMediaRequest(BaseModel): class FetchMediaRequest(BaseModel):
account_id: int account_id: int
chat_id: int chat_id: int
@@ -76,6 +81,16 @@ async def enqueue_backfill(
return EnqueueResponse(job_id=job_id) 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)
@router.post("/media/fetch", status_code=201) @router.post("/media/fetch", status_code=201)
async def enqueue_fetch_media( async def enqueue_fetch_media(
pool: FromDishka[asyncpg.Pool], body: FetchMediaRequest pool: FromDishka[asyncpg.Pool], body: FetchMediaRequest
+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)
+24 -4
View File
@@ -6,6 +6,7 @@ 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.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,
@@ -17,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:
@@ -41,14 +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=IMMUTABLE_HEADERS, headers=headers,
) )
@@ -70,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:
@@ -79,8 +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=DAY_HEADERS, 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
@@ -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",
@@ -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,
)
+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)
+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]
+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}`
);
}
+10 -1
View File
@@ -315,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", {
@@ -367,6 +367,15 @@ export function enqueueBackfill(
}); });
} }
export function enqueueStoriesBackfill(
peerId: number
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/stories/backfill", {
method: "POST",
body: { account_id: accounts.selectedId, peer_id: peerId },
});
}
export function discoverPeers( export function discoverPeers(
query: string, query: string,
remote = false remote = false
+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;
}
+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,
+23 -1
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>
{#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}> <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);
+187 -21
View File
@@ -2,12 +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 { 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 {
@@ -26,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 ?? "") : "");
@@ -39,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);
} }
} }
@@ -52,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) {
@@ -85,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);
} }
} }
@@ -135,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}
@@ -162,19 +249,26 @@
<!-- 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}
@@ -222,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 {
@@ -244,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;
@@ -282,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)} />
+63 -11
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,7 +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 { 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";
@@ -33,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(
@@ -56,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) => {
@@ -64,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;
} }
@@ -86,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>
@@ -102,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"}
@@ -117,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">
@@ -161,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>
@@ -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: "Кастом-эмодзи",
@@ -88,12 +89,12 @@
} }
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;
} }
@@ -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>
@@ -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,9 +1,18 @@
<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";
@@ -11,7 +20,10 @@
import { formatBytes, mediaKindLabel } from "$lib/format/media"; import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster"; 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;
@@ -26,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;
@@ -93,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,
@@ -110,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 {
@@ -128,10 +179,21 @@
{/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 <video
src={ready.url} src={ready.url}
@@ -150,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}
@@ -168,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>
@@ -184,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;
@@ -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 { getChat, 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 { 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 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;
} }
@@ -102,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) {
@@ -127,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}
@@ -145,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}
@@ -154,39 +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
playsinline
preload="metadata"
use:poster
></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}
@@ -216,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,22 +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 { 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);
@@ -90,52 +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
playsinline
preload="metadata"
use:poster
></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>
@@ -150,7 +181,6 @@
</button> </button>
{/if} {/if}
{#if peerId !== null}
<StoryViewer <StoryViewer
{peerId} {peerId}
{items} {items}
@@ -167,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"];
+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)}`;
}
+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;
}
+9
View File
@@ -8,12 +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 { 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";
@@ -98,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;
+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 },
}, },
}, },
}); });