feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
h
2026-05-31 01:27:40 +02:00
parent f0afb7ec5b
commit 75425d1bee
110 changed files with 10199 additions and 54 deletions
@@ -0,0 +1,27 @@
"""media unique_id for edit detection
Revision ID: a9c3e7f1d2b4
Revises: f7a2c9e1b3d5
Create Date: 2026-05-30 04:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "a9c3e7f1d2b4"
down_revision: str | None = "f7a2c9e1b3d5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("media", sa.Column("unique_id", sa.String(), nullable=True))
op.add_column("media_versions", sa.Column("unique_id", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("media_versions", "unique_id")
op.drop_column("media", "unique_id")
@@ -0,0 +1,56 @@
"""media versions (keep every edited media)
Revision ID: f7a2c9e1b3d5
Revises: a3f1c8e94d72
Create Date: 2026-05-30 03:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f7a2c9e1b3d5"
down_revision: str | None = "a3f1c8e94d72"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"media_versions",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.BigInteger(), nullable=False),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column(
"observed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("kind", sa.String(), nullable=False),
sa.Column("storage_key", sa.String(), nullable=False),
sa.Column("file_size", sa.BigInteger(), nullable=True),
sa.Column("mime", sa.String(), nullable=True),
sa.Column("ttl_seconds", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"account_id",
"chat_id",
"message_id",
"storage_key",
name="uq_media_versions_content",
),
)
op.create_index(
"ix_media_versions_message",
"media_versions",
["account_id", "chat_id", "message_id", "observed_at"],
)
def downgrade() -> None:
op.drop_index("ix_media_versions_message", table_name="media_versions")
op.drop_table("media_versions")
+18
View File
@@ -1,16 +1,20 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka, setup_dishka
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastmcp.utilities.lifespan import combine_lifespans
from starlette.applications import Starlette
from api.auth import BearerAuthMiddleware
from api.mcp.server import mcp
from api.routers import (
accounts,
annotations,
avatars,
backfill,
chats,
folders,
@@ -54,12 +58,14 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
return {"db": db_ok, "timescaledb": bool(timescale_ok)}
app.include_router(accounts.router)
app.include_router(policy.router)
app.include_router(folders.router)
app.include_router(backfill.router)
app.include_router(search.router)
app.include_router(chats.router)
app.include_router(media.router)
app.include_router(avatars.router)
app.include_router(social.router)
app.include_router(presence.router)
app.include_router(peers.router)
@@ -68,6 +74,18 @@ app.include_router(watches.router)
app.mount("/mcp", mcp_app)
_spa_dir = Path(env.api.static_dir).resolve()
if _spa_dir.is_dir():
_spa_index = _spa_dir / "index.html"
@app.get("/{spa_path:path}")
async def serve_spa(spa_path: str) -> FileResponse:
candidate = (_spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file():
return FileResponse(candidate)
return FileResponse(_spa_index)
app.add_middleware(BearerAuthMiddleware, token=_token)
setup_dishka(container, app)
+13
View File
@@ -0,0 +1,13 @@
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter
from utils.read import accounts
from utils.read.models import AccountView
router = APIRouter(prefix="/api", tags=["accounts"], route_class=DishkaRoute)
@router.get("/accounts")
async def list_accounts(pool: FromDishka[asyncpg.Pool]) -> list[AccountView]:
return await accounts.list_accounts(pool)
+40
View File
@@ -0,0 +1,40 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.jobs import enqueue
from utils.read.avatars import current_avatar
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/avatars", tags=["avatars"], route_class=DishkaRoute)
@router.get("/{owner_kind}/{owner_id}")
async def serve_avatar(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
owner_kind: str,
owner_id: int,
account_id: Annotated[int, Query()],
) -> FileResponse:
avatar = await current_avatar(pool, account_id, owner_kind, owner_id)
if avatar is None:
raise HTTPException(status_code=404, detail="avatar not found")
if not avatar.downloaded or avatar.storage_key is None:
await enqueue(
pool,
account_id,
"fetch_avatar",
{
"owner_kind": owner_kind,
"owner_id": owner_id,
"unique_id": avatar.unique_id,
},
)
raise HTTPException(status_code=409, detail="avatar not downloaded; fetching")
return FileResponse(
storage.url(avatar.storage_key), media_type=avatar.mime or "image/jpeg"
)
+15
View File
@@ -3,7 +3,9 @@ from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import chats
from utils.read.models import (
DEFAULT_LIMIT,
@@ -15,6 +17,11 @@ from utils.read.models import (
router = APIRouter(prefix="/api", tags=["chats"], route_class=DishkaRoute)
class EnrichRequest(BaseModel):
account_id: int
AccountId = Annotated[int, Query()]
Limit = Annotated[int, Query()]
Offset = Annotated[int, Query()]
@@ -48,6 +55,14 @@ async def chat_history(
)
@router.post("/chats/{chat_id}/enrich")
async def enrich_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, body: EnrichRequest
) -> dict[str, int]:
job_id = await enqueue(pool, body.account_id, "enrich_chat", {"chat_id": chat_id})
return {"job_id": job_id}
@router.get("/chats/{chat_id}/messages/{message_id}/versions")
async def message_versions(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
+48 -3
View File
@@ -1,10 +1,17 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.read.media import get_media
from utils.read.models import MediaView
from utils.read.media import (
get_media,
get_media_version,
get_media_versions,
get_message_media,
)
from utils.read.models import MediaVersionView, MediaView
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
@@ -18,6 +25,44 @@ async def media_meta(pool: FromDishka[asyncpg.Pool], media_id: int) -> MediaView
return media
@router.get("/versions/{chat_id}/{message_id}")
async def message_media_versions(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
message_id: int,
account_id: Annotated[int, Query()],
) -> list[MediaVersionView]:
return await get_media_versions(pool, account_id, chat_id, message_id)
@router.get("/version/{version_id}")
async def serve_media_version(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
version_id: int,
) -> FileResponse:
version = await get_media_version(pool, version_id)
if version is None:
raise HTTPException(status_code=404, detail="media version not found")
return FileResponse(
storage.url(version.storage_key),
media_type=version.mime or "application/octet-stream",
)
@router.get("/message/{chat_id}/{message_id}")
async def message_media(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
message_id: int,
account_id: Annotated[int, Query()],
) -> MediaView:
media = await get_message_media(pool, account_id, chat_id, message_id)
if media is None:
raise HTTPException(status_code=404, detail="media not found")
return media
@router.get("/{media_id}")
async def serve_media(
pool: FromDishka[asyncpg.Pool],
+8
View File
@@ -12,6 +12,14 @@ router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
@router.get("/peers/batch")
async def get_peers(
pool: FromDishka[asyncpg.Pool], account_id: AccountId, ids: Annotated[str, Query()]
) -> list[PeerView]:
parsed = [int(part) for part in ids.split(",") if part.strip()]
return await peers.get_peers(pool, account_id, parsed)
@router.get("/peers/{peer_id}")
async def get_peer(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
+5 -2
View File
@@ -4,7 +4,7 @@ from userbot import PyroClient
from userbot.modules.capture import repository
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.capture.message import sender_id
from userbot.modules.media import self_destruct_ttl
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
@PyroClient.on_edited_message()
@@ -18,7 +18,7 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
toggles = ctx.resolve(meta)
if not toggles.track_edits_deletes:
return
await repository.add_version(
changed = await repository.add_version(
ctx.pool,
ctx.account_id,
chat_id,
@@ -28,9 +28,12 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
message.text or message.caption,
str(message),
message.edit_date,
media_unique_id(message),
has_media=message.media is not None,
is_self_destruct=self_destruct_ttl(message) is not None,
)
if changed and message.media is not None:
await capture_media(client, message, ctx, chat_id, message.id, toggles)
handlers = on_edited_message.handlers
@@ -1,3 +1,4 @@
from userbot.modules.avatars.downloader import capture_avatar
from userbot.modules.avatars.repository import note_avatar
__all__ = ["capture_avatar"]
__all__ = ["capture_avatar", "note_avatar"]
@@ -1,3 +1,5 @@
import json
import asyncpg
_INSERT_AVATAR = """
@@ -13,6 +15,16 @@ SELECT 1 FROM avatars
WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
"""
_GET_FILE = """
SELECT raw ->> 'file_id' AS file_id, downloaded FROM avatars
WHERE account_id = $1 AND owner_kind = $2 AND owner_id = $3 AND unique_id = $4
"""
_MARK_DOWNLOADED = """
UPDATE avatars SET downloaded = true, storage_key = $5, file_size = $6
WHERE account_id = $1 AND owner_kind = $2 AND owner_id = $3 AND unique_id = $4
"""
async def avatar_exists(
pool: asyncpg.Pool, account_id: int, owner_id: int, unique_id: str
@@ -46,3 +58,54 @@ async def insert_avatar( # noqa: PLR0913
downloaded,
raw,
)
async def note_avatar( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
owner_id: int,
owner_kind: str,
unique_id: str,
file_id: str,
) -> None:
await insert_avatar(
pool,
account_id,
owner_id,
owner_kind,
unique_id,
None,
None,
None,
json.dumps({"file_id": file_id}),
downloaded=False,
)
async def get_avatar_file(
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int, unique_id: str
) -> tuple[str | None, bool] | None:
row = await pool.fetchrow(_GET_FILE, account_id, owner_kind, owner_id, unique_id)
if row is None:
return None
return row["file_id"], row["downloaded"]
async def mark_avatar_downloaded( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
owner_kind: str,
owner_id: int,
unique_id: str,
storage_key: str,
file_size: int,
) -> None:
await pool.execute(
_MARK_DOWNLOADED,
account_id,
owner_kind,
owner_id,
unique_id,
storage_key,
file_size,
)
@@ -1,6 +1,7 @@
import asyncpg
from pyrogram import Client
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
from userbot.modules.contacts import ContactCache
from userbot.modules.folders import FolderCache
from userbot.modules.watches import WatchCache
@@ -25,6 +26,8 @@ class CaptureContext:
self.folders = folders
self.contacts = contacts
self.watches = WatchCache(pool, account_id)
self.peer_identity = PeerIdentityCache()
self.chat_meta = ChatMetaCache()
self.policies = PolicySet()
async def reload_policies(self) -> None:
@@ -0,0 +1,110 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from userbot.modules.capture.chat_meta import chat_kind
from userbot.modules.profiles.parse import ProfileFields, snapshot_from_high_level
from userbot.modules.profiles.repository import get_peer, write_profile
from utils.policy.models import ChatKind
if TYPE_CHECKING:
import asyncpg
from pyrogram.types import Message
from userbot.modules.capture.context import CaptureContext
class PeerIdentityCache:
def __init__(self) -> None:
self._cache: dict[int, ProfileFields | None] = {}
async def changed(
self, pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields
) -> bool:
if peer_id not in self._cache:
self._cache[peer_id] = await get_peer(pool, account_id, peer_id)
if self._cache[peer_id] == fields:
return False
self._cache[peer_id] = fields
return True
class ChatMetaCache:
def __init__(self) -> None:
self._cache: dict[int, tuple[str | None, str | None]] = {}
async def changed(
self,
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
meta: tuple[str | None, str | None],
) -> bool:
from userbot.modules.groups.repository import ( # noqa: PLC0415
get_latest_chat_meta,
)
if chat_id not in self._cache:
self._cache[chat_id] = await get_latest_chat_meta(pool, account_id, chat_id)
if self._cache[chat_id] == meta:
return False
self._cache[chat_id] = meta
return True
async def _capture_peer(message: Message, ctx: CaptureContext) -> None:
user = message.from_user
if user is None:
return
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
return
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user))
if photo_file_id and photo_unique_id:
from userbot.modules.avatars import note_avatar # noqa: PLC0415
await note_avatar(
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
)
async def _capture_chat(message: Message, ctx: CaptureContext) -> None:
chat = message.chat
if (
chat is None
or chat.id is None
or message.date is None
or chat_kind(chat.type) is ChatKind.DM
):
return
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
meta = (chat.title, photo_unique_id)
if not await ctx.chat_meta.changed(ctx.pool, ctx.account_id, chat.id, meta):
return
from userbot.modules.groups.repository import insert_chat_history # noqa: PLC0415
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat.id,
message.id,
"meta",
chat.title,
photo_unique_id,
None,
message.date,
str(message),
)
if photo_file_id and photo_unique_id:
from userbot.modules.avatars import note_avatar # noqa: PLC0415
await note_avatar(
ctx.pool, ctx.account_id, chat.id, "chat", photo_unique_id, photo_file_id
)
async def capture_identity(message: Message, ctx: CaptureContext) -> None:
await _capture_peer(message, ctx)
await _capture_chat(message, ctx)
@@ -51,6 +51,9 @@ async def capture_message(
has_media=message.media is not None,
is_self_destruct=self_destruct_ttl(message) is not None,
)
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
await capture_identity(message, ctx)
await capture_media(client, message, ctx, chat_id, message.id, toggles)
buttons = callbacks(message)
if buttons:
@@ -23,28 +23,71 @@ INSERT INTO messages
has_media, is_self_destruct, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
text = EXCLUDED.text,
raw = EXCLUDED.raw,
has_media = EXCLUDED.has_media,
is_self_destruct = EXCLUDED.is_self_destruct,
edited_at = now()
"""
_INSERT_VERSION = """
INSERT INTO message_versions
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
VALUES ($1, $2, $3, now(), $4, $5, $6::jsonb)
VALUES ($1, $2, $3, clock_timestamp(), $4, $5, $6::jsonb)
ON CONFLICT DO NOTHING
"""
_SNAPSHOT_ORIGINAL = """
INSERT INTO message_versions
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
FROM messages m
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
AND NOT EXISTS (
SELECT 1 FROM message_versions v
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
AND v.message_id = m.message_id
)
ON CONFLICT DO NOTHING
"""
_CURRENT_CONTENT = """
SELECT
m.text AS text,
(SELECT d.unique_id FROM media d
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
AND d.message_id = m.message_id) AS media_unique_id
FROM messages m
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
ORDER BY m.date DESC LIMIT 1
"""
_CURRENT_MEDIA = """
SELECT unique_id, storage_key, file_size, downloaded FROM media
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
"""
_INSERT_MEDIA_VERSION = """
INSERT INTO media_versions
(account_id, chat_id, message_id, observed_at, kind, storage_key,
file_size, mime, ttl_seconds, unique_id)
VALUES ($1, $2, $3, clock_timestamp(), $4, $5, $6, $7, $8, $9)
ON CONFLICT (account_id, chat_id, message_id, storage_key) DO NOTHING
"""
_INSERT_MEDIA = """
INSERT INTO media
(account_id, chat_id, message_id, kind, storage_key, file_size, mime,
ttl_seconds, downloaded)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ttl_seconds, downloaded, unique_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
kind = EXCLUDED.kind,
storage_key = EXCLUDED.storage_key,
file_size = EXCLUDED.file_size,
mime = EXCLUDED.mime,
ttl_seconds = EXCLUDED.ttl_seconds,
downloaded = EXCLUDED.downloaded
downloaded = EXCLUDED.downloaded,
unique_id = EXCLUDED.unique_id
"""
@@ -111,11 +154,20 @@ async def add_version( # noqa: PLR0913
text: str | None,
raw: str,
edit_date: datetime | None,
media_unique_id: str | None,
*,
has_media: bool,
is_self_destruct: bool,
) -> None:
) -> bool:
async with pool.acquire() as conn, conn.transaction():
current = await conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id)
text_changed = current is None or current["text"] != text
media_changed = (
current is not None and current["media_unique_id"] != media_unique_id
)
if not (text_changed or media_changed):
return False
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id)
await conn.execute(
_TOUCH_EDITED,
account_id,
@@ -131,6 +183,13 @@ async def add_version( # noqa: PLR0913
await conn.execute(
_INSERT_VERSION, account_id, chat_id, message_id, edit_date, text, raw
)
return True
async def current_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> asyncpg.Record | None:
return await pool.fetchrow(_CURRENT_MEDIA, account_id, chat_id, message_id)
async def insert_media( # noqa: PLR0913
@@ -143,21 +202,37 @@ async def insert_media( # noqa: PLR0913
file_size: int | None,
mime: str | None,
ttl_seconds: int | None,
unique_id: str | None,
*,
downloaded: bool,
) -> None:
await pool.execute(
_INSERT_MEDIA,
account_id,
chat_id,
message_id,
kind,
storage_key,
file_size,
mime,
ttl_seconds,
downloaded,
)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
_INSERT_MEDIA,
account_id,
chat_id,
message_id,
kind,
storage_key,
file_size,
mime,
ttl_seconds,
downloaded,
unique_id,
)
if storage_key is not None:
await conn.execute(
_INSERT_MEDIA_VERSION,
account_id,
chat_id,
message_id,
kind,
storage_key,
file_size,
mime,
ttl_seconds,
unique_id,
)
async def insert_callbacks(
@@ -17,6 +17,26 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
ON CONFLICT (account_id, chat_id, message_id, user_id) DO NOTHING
"""
_LATEST_TITLE = """
SELECT title FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND title IS NOT NULL
ORDER BY ts DESC LIMIT 1
"""
_LATEST_PHOTO = """
SELECT photo_unique_id FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND photo_unique_id IS NOT NULL
ORDER BY ts DESC LIMIT 1
"""
async def get_latest_chat_meta(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> tuple[str | None, str | None]:
title = await pool.fetchval(_LATEST_TITLE, account_id, chat_id)
photo_unique_id = await pool.fetchval(_LATEST_PHOTO, account_id, chat_id)
return title, photo_unique_id
async def insert_chat_history( # noqa: PLR0913
pool: asyncpg.Pool,
@@ -1,3 +1,9 @@
from userbot.modules.jobs.handlers import backfill, fetch_media, transcribe
from userbot.modules.jobs.handlers import (
backfill,
enrich_chat,
fetch_avatar,
fetch_media,
transcribe,
)
__all__ = ["backfill", "fetch_media", "transcribe"]
__all__ = ["backfill", "enrich_chat", "fetch_avatar", "fetch_media", "transcribe"]
@@ -0,0 +1,91 @@
from datetime import UTC, datetime
from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
MEMBER_CAP = 200
_MISSING_SENDERS = """
SELECT DISTINCT sender_id FROM messages
WHERE account_id = $1 AND chat_id = $2 AND sender_id > 0
AND NOT EXISTS (
SELECT 1 FROM peers p WHERE p.account_id = $1 AND p.peer_id = messages.sender_id
)
LIMIT 100
"""
async def _save_user(ctx: CaptureContext, user: User) -> None:
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
)
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
chat = await client.get_chat(chat_id)
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
str(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
async def _enrich_members(client: Client, ctx: CaptureContext, chat_id: int) -> None:
try:
async for member in client.get_chat_members(chat_id, limit=MEMBER_CAP):
if isinstance(member.user, User):
await _save_user(ctx, member.user)
except (BadRequest, Forbidden):
return
async def _enrich_senders(client: Client, ctx: CaptureContext, chat_id: int) -> None:
rows = await ctx.pool.fetch(_MISSING_SENDERS, ctx.account_id, chat_id)
ids = [row["sender_id"] for row in rows]
for sender_id in ids:
try:
user = await client.get_users(sender_id)
except BadRequest:
continue
if isinstance(user, User):
await _save_user(ctx, user)
@register("enrich_chat")
async def enrich_chat(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
chat_id = ctx.job.params["chat_id"]
await _enrich_chat_meta(client, capture, chat_id)
await _enrich_members(client, capture, chat_id)
await _enrich_senders(client, capture, chat_id)
@@ -0,0 +1,40 @@
from io import BytesIO
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
@register("fetch_avatar")
async def fetch_avatar(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
owner_kind = ctx.job.params["owner_kind"]
owner_id = ctx.job.params["owner_id"]
unique_id = ctx.job.params["unique_id"]
found = await get_avatar_file(
ctx.pool, ctx.account_id, owner_kind, owner_id, unique_id
)
if found is None:
return
file_id, downloaded = found
if downloaded or file_id is None:
return
buffer = await client.download_media(file_id, in_memory=True)
if not isinstance(buffer, BytesIO):
return
data = buffer.getvalue()
storage_key = capture.storage.put(data)
await mark_avatar_downloaded(
ctx.pool,
ctx.account_id,
owner_kind,
owner_id,
unique_id,
storage_key,
len(data),
)
@@ -1,3 +1,7 @@
from userbot.modules.media.downloader import capture_media, self_destruct_ttl
from userbot.modules.media.downloader import (
capture_media,
media_unique_id,
self_destruct_ttl,
)
__all__ = ["capture_media", "self_destruct_ttl"]
__all__ = ["capture_media", "media_unique_id", "self_destruct_ttl"]
@@ -33,6 +33,11 @@ def self_destruct_ttl(message: Message) -> int | None:
return getattr(obj, "ttl_seconds", None) if obj is not None else None
def media_unique_id(message: Message) -> str | None:
_, obj = media_object(message)
return getattr(obj, "file_unique_id", None) if obj is not None else None
async def capture_media( # noqa: PLR0913
client: Client,
message: Message,
@@ -44,6 +49,7 @@ async def capture_media( # noqa: PLR0913
kind, obj = media_object(message)
if obj is None:
return
unique_id = getattr(obj, "file_unique_id", None)
ttl = getattr(obj, "ttl_seconds", None)
want = toggles.self_destruct_media if ttl else toggles.media
file_size = getattr(obj, "file_size", None)
@@ -51,12 +57,25 @@ async def capture_media( # noqa: PLR0913
storage_key: str | None = None
downloaded = False
if want:
buffer = await client.download_media(message, in_memory=True)
if isinstance(buffer, BytesIO):
data = buffer.getvalue()
storage_key = ctx.storage.put(data)
file_size = len(data)
existing = await repository.current_media(
ctx.pool, ctx.account_id, chat_id, message_id
)
if (
existing is not None
and existing["downloaded"]
and existing["unique_id"] == unique_id
and existing["storage_key"] is not None
):
storage_key = existing["storage_key"]
file_size = existing["file_size"]
downloaded = True
else:
buffer = await client.download_media(message, in_memory=True)
if isinstance(buffer, BytesIO):
data = buffer.getvalue()
storage_key = ctx.storage.put(data)
file_size = len(data)
downloaded = True
await repository.insert_media(
ctx.pool,
ctx.account_id,
@@ -67,5 +86,6 @@ async def capture_media( # noqa: PLR0913
file_size,
mime,
ttl,
unique_id,
downloaded=downloaded,
)
@@ -1,7 +1,13 @@
from userbot.modules.profiles.parse import (
ProfileFields,
active_username,
snapshot_from_high_level,
snapshot_from_user,
)
__all__ = ["ProfileFields", "active_username", "snapshot_from_user"]
__all__ = [
"ProfileFields",
"active_username",
"snapshot_from_high_level",
"snapshot_from_user",
]
@@ -37,3 +37,20 @@ def snapshot_from_user(
is_deleted_account=bool(getattr(raw_user, "deleted", False)),
)
return fields, photo_file_id, photo_unique_id
def snapshot_from_high_level(
user: User,
) -> tuple[ProfileFields, str | None, str | None]:
photo = user.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
fields = ProfileFields(
first_name=user.first_name,
last_name=user.last_name,
username=user.username,
phone=user.phone_number,
photo_unique_id=photo_unique_id,
is_deleted_account=bool(user.is_deleted),
)
return fields, photo_file_id, photo_unique_id
+1
View File
@@ -34,6 +34,7 @@ class TelegramSettings(BaseSettings):
class ApiSettings(BaseSettings):
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
static_dir: str = "static"
class AuthSettings(BaseSettings):
+11
View File
@@ -0,0 +1,11 @@
import asyncpg
from utils.read.models import AccountView
async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]:
rows = await pool.fetch(
"SELECT account_id, label, phone, tg_user_id, is_active FROM accounts "
"ORDER BY account_id"
)
return [AccountView(**dict(row)) for row in rows]
+30
View File
@@ -0,0 +1,30 @@
import asyncpg
from utils.read.models import AvatarRef
_PEER_UNIQUE_ID = """
SELECT photo_unique_id FROM peers
WHERE account_id = $1 AND peer_id = $2
"""
_CHAT_UNIQUE_ID = """
SELECT photo_unique_id FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND photo_unique_id IS NOT NULL
ORDER BY ts DESC LIMIT 1
"""
_AVATAR = """
SELECT unique_id, storage_key, downloaded, mime FROM avatars
WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
"""
async def current_avatar(
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int
) -> AvatarRef | None:
query = _PEER_UNIQUE_ID if owner_kind == "peer" else _CHAT_UNIQUE_ID
unique_id = await pool.fetchval(query, account_id, owner_id)
if unique_id is None:
return None
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
return AvatarRef(**dict(row)) if row else None
+106 -6
View File
@@ -1,13 +1,46 @@
import asyncpg
from utils.read.models import ChatListItem, MessageVersionView, MessageView, Page
from utils.read.message_view import build_message_view, load_raw, media_ref_from
from utils.read.models import (
ChatListItem,
MediaRef,
MessageVersionView,
MessageView,
Page,
)
_MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, "
"has_media, is_self_destruct, edited_at, deleted_at"
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
"edited_at, deleted_at, raw, raw->>'media_group_id' AS media_group_id"
)
async def _media_map(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> dict[tuple[int, int], asyncpg.Record]:
message_ids = list({row["message_id"] for row in rows})
if not message_ids:
return {}
media_rows = await pool.fetch(
"SELECT id, chat_id, message_id, kind, downloaded, mime, file_size, "
"ttl_seconds FROM media "
"WHERE account_id = $1 AND message_id = ANY($2::bigint[])",
account_id,
message_ids,
)
return {(row["chat_id"], row["message_id"]): row for row in media_rows}
def _single_media(
row: asyncpg.Record, raw: dict, media_by_key: dict[tuple[int, int], asyncpg.Record]
) -> list[MediaRef]:
media_row = media_by_key.get((row["chat_id"], row["message_id"]))
if not (row["has_media"] or media_row):
return []
ref = media_ref_from(row["message_id"], raw, media_row)
return [ref] if ref else []
def _peer_title(
first: str | None, last: str | None, username: str | None
) -> str | None:
@@ -28,7 +61,24 @@ async def list_chats(
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS username, "
"(SELECT ch.title FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title "
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title, "
"EXISTS (SELECT 1 FROM avatars a "
"WHERE a.account_id = $1 AND a.owner_id = m.chat_id) AS has_avatar, "
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, false) "
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS is_bot, "
"(SELECT COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, "
"false) FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS is_contact, "
"(SELECT ch.raw->'chat'->>'type' = 'ChatType.CHANNEL' FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"AND ch.raw->'chat'->>'type' IS NOT NULL "
"ORDER BY ch.ts DESC LIMIT 1) AS is_broadcast, "
"(SELECT lm.text FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = m.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_text, "
"(SELECT lm.sender_id FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = m.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_sender_id "
"FROM messages m WHERE m.account_id = $1 "
"GROUP BY m.chat_id ORDER BY last_date DESC LIMIT $2 OFFSET $3",
account_id,
@@ -44,8 +94,15 @@ async def list_chats(
ChatListItem(
chat_id=row["chat_id"],
title=title,
kind="private" if row["chat_id"] > 0 else "group",
has_avatar=row["has_avatar"],
is_bot=bool(row["is_bot"]),
is_contact=bool(row["is_contact"]),
is_broadcast=bool(row["is_broadcast"]),
message_count=row["message_count"],
last_date=row["last_date"],
last_text=row["last_text"],
last_sender_id=row["last_sender_id"],
)
)
return items
@@ -70,7 +127,43 @@ async def get_chat_history(
page.capped_limit,
page.offset,
)
return [MessageView(**dict(row)) for row in rows]
media_by_key = await _media_map(pool, account_id, rows)
parsed = [(row, load_raw(row["raw"])) for row in rows]
views: list[MessageView] = []
index = 0
while index < len(parsed):
group_id = parsed[index][0]["media_group_id"]
end = index + 1
if group_id is not None:
while end < len(parsed) and parsed[end][0]["media_group_id"] == group_id:
end += 1
members = parsed[index:end]
if len(members) == 1:
row, raw = members[0]
views.append(
build_message_view(row, raw, _single_media(row, raw, media_by_key))
)
else:
views.append(_build_album(members, media_by_key))
index = end
return views
def _build_album(
members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record],
) -> MessageView:
ordered = sorted(members, key=lambda m: m[0]["message_id"])
media: list[MediaRef] = []
for row, raw in ordered:
media_row = media_by_key.get((row["chat_id"], row["message_id"]))
ref = media_ref_from(row["message_id"], raw, media_row)
if ref:
media.append(ref)
primary_row, primary_raw = next(
((row, raw) for row, raw in ordered if row["text"]), ordered[0]
)
return build_message_view(primary_row, primary_raw, media)
async def get_deleted_messages(
@@ -88,7 +181,14 @@ async def get_deleted_messages(
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params,
)
return [MessageView(**dict(row)) for row in rows]
media_by_key = await _media_map(pool, account_id, rows)
views: list[MessageView] = []
for row in rows:
raw = load_raw(row["raw"])
views.append(
build_message_view(row, raw, _single_media(row, raw, media_by_key))
)
return views
async def get_message_versions(
+27 -1
View File
@@ -1,12 +1,14 @@
import asyncpg
from utils.read.models import MediaView
from utils.read.models import MediaVersionView, MediaView
_MEDIA_COLS = (
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
"mime, ttl_seconds, downloaded, extracted_text, created_at"
)
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
row = await pool.fetchrow(
@@ -27,3 +29,27 @@ async def get_message_media(
message_id,
)
return MediaView(**dict(row)) if row else None
async def get_media_versions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MediaVersionView]:
rows = await pool.fetch(
f"SELECT {_VERSION_COLS} FROM media_versions " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY observed_at",
account_id,
chat_id,
message_id,
)
return [MediaVersionView(**dict(row)) for row in rows]
async def get_media_version(
pool: asyncpg.Pool, version_id: int
) -> MediaVersionView | None:
row = await pool.fetchrow(
f"SELECT {_VERSION_COLS} FROM media_versions WHERE id = $1", # noqa: S608
version_id,
)
return MediaVersionView(**dict(row)) if row else None
+404
View File
@@ -0,0 +1,404 @@
import json
from datetime import datetime
from typing import Any
import asyncpg
from pydantic import ValidationError
from utils.read.models import (
ContactView,
EntityView,
ForwardView,
InlineButton,
LocationView,
MediaRef,
MessageView,
PollOption,
PollView,
ReactionCount,
ReplyView,
ServiceView,
StickerView,
WebPageView,
)
_MEDIA_KEYS = (
"photo",
"video",
"animation",
"voice",
"video_note",
"audio",
"document",
"sticker",
)
def load_raw(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _enum(value: object) -> str | None:
if not isinstance(value, str):
return None
return value.rsplit(".", 1)[-1].lower()
def _parse_dt(value: object) -> datetime | None:
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def _peer_name(user: dict[str, Any]) -> str | None:
name = " ".join(
part for part in (user.get("first_name"), user.get("last_name")) if part
)
return name or user.get("username")
def _entities(raw: dict[str, Any]) -> list[EntityView]:
source = raw.get("entities") or raw.get("caption_entities") or []
if not isinstance(source, list):
return []
out: list[EntityView] = []
for item in source:
if not isinstance(item, dict):
continue
kind = _enum(item.get("type"))
offset = item.get("offset")
length = item.get("length")
if kind is None or not isinstance(offset, int) or not isinstance(length, int):
continue
custom = item.get("custom_emoji_id")
out.append(
EntityView(
type=kind,
offset=offset,
length=length,
url=item.get("url"),
custom_emoji_id=str(custom) if custom is not None else None,
language=item.get("language"),
)
)
return out
def _media_kind(message: dict[str, Any]) -> str | None:
kind = _enum(message.get("media"))
if kind:
return kind
for key in _MEDIA_KEYS:
if key in message:
return key
return None
def _reply(raw: dict[str, Any]) -> ReplyView | None:
reply = raw.get("reply_to_message")
reply_id = raw.get("reply_to_message_id")
if not isinstance(reply, dict):
return ReplyView(message_id=reply_id) if reply_id else None
sender = reply.get("from_user")
sender_chat = reply.get("sender_chat")
sender_id = None
sender_name = None
if isinstance(sender, dict):
sender_id = sender.get("id")
sender_name = _peer_name(sender)
elif isinstance(sender_chat, dict):
sender_id = sender_chat.get("id")
sender_name = sender_chat.get("title")
return ReplyView(
message_id=reply.get("id") or reply_id,
sender_id=sender_id,
sender_name=sender_name,
text=reply.get("text") or reply.get("caption"),
media_kind=_media_kind(reply),
)
def _forward(raw: dict[str, Any]) -> ForwardView | None:
origin = raw.get("forward_origin")
if not isinstance(origin, dict):
return None
tag = origin.get("_")
date = _parse_dt(origin.get("date"))
if tag == "MessageOriginUser":
user = origin.get("sender_user")
user = user if isinstance(user, dict) else {}
return ForwardView(
kind="user", from_id=user.get("id"), from_name=_peer_name(user), date=date
)
if tag == "MessageOriginChannel":
chat = origin.get("chat")
chat = chat if isinstance(chat, dict) else {}
return ForwardView(
kind="channel",
chat_id=chat.get("id"),
chat_title=chat.get("title"),
message_id=origin.get("message_id"),
signature=origin.get("author_signature"),
date=date,
)
return ForwardView(
kind="hidden", from_name=origin.get("sender_user_name"), date=date
)
def _reactions(raw: dict[str, Any]) -> list[ReactionCount]:
container = raw.get("reactions")
if not isinstance(container, dict):
return []
items = container.get("reactions")
if not isinstance(items, list):
return []
out: list[ReactionCount] = []
for item in items:
if not isinstance(item, dict):
continue
custom = item.get("custom_emoji_id")
out.append(
ReactionCount(
emoji=item.get("emoji"),
custom_emoji_id=str(custom) if custom is not None else None,
count=item.get("count") or 0,
chosen="chosen_order" in item,
)
)
return out
def _button_kind(button: dict[str, Any]) -> str:
if button.get("url"):
return "url"
if button.get("callback_data") is not None:
return "callback"
if "switch_inline_query" in button or "switch_inline_query_current_chat" in button:
return "switch"
return "other"
def _inline_buttons(raw: dict[str, Any]) -> list[list[InlineButton]]:
markup = raw.get("reply_markup")
if not isinstance(markup, dict):
return []
rows = markup.get("inline_keyboard")
if not isinstance(rows, list):
return []
out: list[list[InlineButton]] = []
for row in rows:
if not isinstance(row, list):
continue
buttons: list[InlineButton] = []
for button in row:
if not isinstance(button, dict):
continue
data = button.get("callback_data")
buttons.append(
InlineButton(
text=button.get("text") or "",
kind=_button_kind(button),
url=button.get("url"),
data=data if isinstance(data, str) else None,
)
)
if buttons:
out.append(buttons)
return out
def _web_page(raw: dict[str, Any]) -> WebPageView | None:
page = raw.get("web_page")
if not isinstance(page, dict) or not page.get("url"):
return None
return WebPageView(
url=page["url"],
display_url=page.get("display_url"),
type=page.get("type"),
site_name=page.get("site_name"),
title=page.get("title"),
description=page.get("description"),
has_photo="photo" in page,
)
def _text_of(value: dict[str, Any] | str | None) -> str | None:
if isinstance(value, dict):
text = value.get("text")
return text if isinstance(text, str) else None
return value if isinstance(value, str) else None
def _poll(raw: dict[str, Any]) -> PollView | None:
poll = raw.get("poll")
if not isinstance(poll, dict):
return None
raw_options = poll.get("options")
options: list[PollOption] = []
if isinstance(raw_options, list):
for option in raw_options:
if not isinstance(option, dict):
continue
options.append(
PollOption(
text=_text_of(option.get("text")) or "",
voter_count=option.get("voter_count") or 0,
vote_percentage=option.get("vote_percentage") or 0,
correct=option.get("is_correct"),
)
)
return PollView(
question=_text_of(poll.get("question")) or "",
options=options,
total_voter_count=poll.get("total_voter_count") or 0,
quiz=_enum(poll.get("type")) == "quiz",
closed=bool(poll.get("is_closed")),
multiple=bool(poll.get("allows_multiple_answers")),
anonymous=bool(poll.get("is_anonymous", True)),
)
def _contact(raw: dict[str, Any]) -> ContactView | None:
contact = raw.get("contact")
if not isinstance(contact, dict):
return None
return ContactView(
user_id=contact.get("user_id"),
first_name=contact.get("first_name"),
last_name=contact.get("last_name"),
phone_number=contact.get("phone_number"),
)
def _location(raw: dict[str, Any]) -> LocationView | None:
venue = raw.get("venue")
if isinstance(venue, dict):
point = venue.get("location")
point = point if isinstance(point, dict) else {}
return LocationView(
latitude=point.get("latitude"),
longitude=point.get("longitude"),
title=venue.get("title"),
address=venue.get("address"),
)
point = raw.get("location")
if not isinstance(point, dict):
return None
return LocationView(
latitude=point.get("latitude"), longitude=point.get("longitude")
)
def _service(raw: dict[str, Any]) -> ServiceView | None:
kind = _enum(raw.get("service"))
if kind is None:
return None
members = raw.get("new_chat_members") or raw.get("left_chat_member")
member_ids = None
if isinstance(members, list):
member_ids = [m["id"] for m in members if isinstance(m, dict) and "id" in m]
elif isinstance(members, dict) and "id" in members:
member_ids = [members["id"]]
pinned = raw.get("pinned_message")
call = raw.get("phone_call_ended")
return ServiceView(
kind=kind,
member_ids=member_ids,
pinned_message_id=pinned.get("id") if isinstance(pinned, dict) else None,
duration=call.get("duration") if isinstance(call, dict) else None,
)
def _sticker(raw: dict[str, Any]) -> StickerView | None:
sticker = raw.get("sticker")
if not isinstance(sticker, dict):
return None
return StickerView(
emoji=sticker.get("emoji"),
set_name=sticker.get("set_name"),
width=sticker.get("width"),
height=sticker.get("height"),
mime=sticker.get("mime_type"),
is_animated=bool(sticker.get("is_animated")),
is_video=bool(sticker.get("is_video")),
)
def media_ref_from(
message_id: int, raw: dict[str, Any], media_row: asyncpg.Record | None
) -> MediaRef | None:
kind = (media_row["kind"] if media_row else None) or _media_kind(raw)
if kind is None:
return None
obj = raw.get(kind)
obj = obj if isinstance(obj, dict) else {}
width = obj.get("width") or obj.get("length")
height = obj.get("height") or obj.get("length")
return MediaRef(
message_id=message_id,
id=media_row["id"] if media_row else None,
kind=kind,
downloaded=bool(media_row["downloaded"]) if media_row else False,
width=width,
height=height,
duration=obj.get("duration"),
mime=(media_row["mime"] if media_row else None) or obj.get("mime_type"),
file_size=(media_row["file_size"] if media_row else None)
or obj.get("file_size"),
ttl_seconds=media_row["ttl_seconds"] if media_row else None,
)
def _base_fields(row: asyncpg.Record) -> dict[str, Any]:
return {
"chat_id": row["chat_id"],
"message_id": row["message_id"],
"date": row["date"],
"sender_id": row["sender_id"],
"text": row["text"],
"has_media": row["has_media"],
"is_self_destruct": row["is_self_destruct"],
"edited_at": row["edited_at"],
"deleted_at": row["deleted_at"],
"media_group_id": row["media_group_id"],
}
def build_message_view(
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
) -> MessageView:
base = _base_fields(row)
via_bot = raw.get("via_bot")
sticker = _sticker(raw)
try:
return MessageView(
**base,
entities=_entities(raw),
quote=_text_of(raw.get("quote")),
reply=_reply(raw),
forward=_forward(raw),
media=media,
reactions=_reactions(raw),
inline_buttons=_inline_buttons(raw),
web_page=_web_page(raw),
poll=_poll(raw),
contact=_contact(raw),
location=_location(raw),
service=_service(raw),
via_bot_id=via_bot.get("id") if isinstance(via_bot, dict) else None,
sticker=sticker,
is_sticker=sticker is not None,
is_animated_emoji=False,
)
except ValidationError:
return MessageView(**base, media=media)
+162
View File
@@ -16,11 +16,139 @@ class Page(BaseModel):
return min(self.limit, MAX_LIMIT)
class AccountView(BaseModel):
account_id: int
label: str | None
phone: str | None
tg_user_id: int | None
is_active: bool
class ChatListItem(BaseModel):
chat_id: int
title: str | None
kind: str
has_avatar: bool
is_bot: bool
is_contact: bool
is_broadcast: bool
message_count: int
last_date: datetime | None
last_text: str | None
last_sender_id: int | None
class EntityView(BaseModel):
type: str
offset: int
length: int
url: str | None = None
custom_emoji_id: str | None = None
language: str | None = None
class ReplyView(BaseModel):
message_id: int | None = None
sender_id: int | None = None
sender_name: str | None = None
text: str | None = None
media_kind: str | None = None
class ForwardView(BaseModel):
kind: str
from_id: int | None = None
from_name: str | None = None
chat_id: int | None = None
chat_title: str | None = None
message_id: int | None = None
date: datetime | None = None
signature: str | None = None
class MediaRef(BaseModel):
message_id: int
id: int | None = None
kind: str
downloaded: bool = False
width: int | None = None
height: int | None = None
duration: float | None = None
mime: str | None = None
file_size: int | None = None
ttl_seconds: int | None = None
class ReactionCount(BaseModel):
emoji: str | None = None
custom_emoji_id: str | None = None
count: int
chosen: bool = False
class InlineButton(BaseModel):
text: str
kind: str
url: str | None = None
data: str | None = None
class WebPageView(BaseModel):
url: str
display_url: str | None = None
type: str | None = None
site_name: str | None = None
title: str | None = None
description: str | None = None
has_photo: bool = False
class PollOption(BaseModel):
text: str
voter_count: int = 0
vote_percentage: int = 0
correct: bool | None = None
class PollView(BaseModel):
question: str
options: list[PollOption] = []
total_voter_count: int = 0
quiz: bool = False
closed: bool = False
multiple: bool = False
anonymous: bool = True
class ContactView(BaseModel):
user_id: int | None = None
first_name: str | None = None
last_name: str | None = None
phone_number: str | None = None
class LocationView(BaseModel):
latitude: float | None = None
longitude: float | None = None
title: str | None = None
address: str | None = None
class ServiceView(BaseModel):
kind: str
member_ids: list[int] | None = None
pinned_message_id: int | None = None
duration: int | None = None
class StickerView(BaseModel):
emoji: str | None = None
set_name: str | None = None
width: int | None = None
height: int | None = None
mime: str | None = None
is_animated: bool = False
is_video: bool = False
class MessageView(BaseModel):
@@ -33,6 +161,23 @@ class MessageView(BaseModel):
is_self_destruct: bool
edited_at: datetime | None
deleted_at: datetime | None
entities: list[EntityView] = []
quote: str | None = None
reply: ReplyView | None = None
forward: ForwardView | None = None
media_group_id: str | None = None
media: list[MediaRef] = []
reactions: list[ReactionCount] = []
inline_buttons: list[list[InlineButton]] = []
web_page: WebPageView | None = None
poll: PollView | None = None
contact: ContactView | None = None
location: LocationView | None = None
service: ServiceView | None = None
via_bot_id: int | None = None
sticker: StickerView | None = None
is_sticker: bool = False
is_animated_emoji: bool = False
class MessageVersionView(BaseModel):
@@ -56,6 +201,22 @@ class MediaView(BaseModel):
created_at: datetime
class MediaVersionView(BaseModel):
id: int
kind: str
storage_key: str
file_size: int | None
mime: str | None
observed_at: datetime
class AvatarRef(BaseModel):
unique_id: str
storage_key: str | None
downloaded: bool
mime: str | None
class CallbackView(BaseModel):
position: int
label: str | None
@@ -103,6 +264,7 @@ class PeerView(BaseModel):
phone: str | None
photo_unique_id: str | None
is_deleted_account: bool
has_avatar: bool
updated_at: datetime
+22 -2
View File
@@ -2,13 +2,19 @@ import asyncpg
from utils.read.models import Page, PeerHistoryView, PeerView, StoryView
_PEER_COLS = (
"peer_id, first_name, last_name, username, phone, photo_unique_id, "
"is_deleted_account, updated_at, "
"EXISTS (SELECT 1 FROM avatars a WHERE a.account_id = peers.account_id "
"AND a.owner_id = peers.peer_id) AS has_avatar"
)
async def get_peer(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> PeerView | None:
row = await pool.fetchrow(
"SELECT peer_id, first_name, last_name, username, phone, "
"photo_unique_id, is_deleted_account, updated_at FROM peers "
f"SELECT {_PEER_COLS} FROM peers " # noqa: S608
"WHERE account_id = $1 AND peer_id = $2",
account_id,
peer_id,
@@ -16,6 +22,20 @@ async def get_peer(
return PeerView(**dict(row)) if row else None
async def get_peers(
pool: asyncpg.Pool, account_id: int, ids: list[int]
) -> list[PeerView]:
if not ids:
return []
rows = await pool.fetch(
f"SELECT {_PEER_COLS} FROM peers " # noqa: S608
"WHERE account_id = $1 AND peer_id = ANY($2)",
account_id,
ids,
)
return [PeerView(**dict(row)) for row in rows]
async def get_peer_history(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> list[PeerHistoryView]:
+4
View File
@@ -6,3 +6,7 @@ services:
api:
ports:
- "127.0.0.1:8080:8080"
frontend-dev:
ports:
- "127.0.0.1:5173:5173"
+14
View File
@@ -72,5 +72,19 @@ services:
entrypoint: [alembic]
command: [upgrade, head]
frontend-dev:
image: oven/bun:1
profiles: [frontend]
working_dir: /app
command: ["sh", "-c", "bun install && bun run dev --host 0.0.0.0 --port 5173"]
environment:
API_PROXY_TARGET: http://api:8080
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
- frontend_svelte_kit:/app/.svelte-kit
volumes:
pgdata:
frontend_node_modules:
frontend_svelte_kit:
+12
View File
@@ -26,6 +26,7 @@
"rules": {
"correctness": {
"noUndeclaredVariables": "off",
"noUnusedFunctionParameters": "off",
"noUnusedVariables": "off"
},
"style": {
@@ -52,6 +53,17 @@
"includes": ["src/app.html"],
"linter": { "enabled": false },
"formatter": { "enabled": false }
},
{
"includes": ["src/lib/styles/**"],
"linter": {
"rules": {
"suspicious": {
"noDuplicateProperties": "off"
}
}
},
"formatter": { "enabled": false }
}
]
}
+80 -2
View File
@@ -1,8 +1,12 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "frontend",
"dependencies": {
"bits-ui": "^2.18.1",
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@sveltejs/adapter-auto": "^7.0.1",
@@ -11,6 +15,8 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^25.9.1",
"sass": "^1.100.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
@@ -49,6 +55,14 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -63,6 +77,34 @@
"@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="],
@@ -109,6 +151,8 @@
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="],
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
@@ -147,6 +191,8 @@
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -157,9 +203,11 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"bits-ui": ["bits-ui@2.18.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA=="],
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
@@ -175,6 +223,8 @@
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
@@ -199,6 +249,14 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"immutable": ["immutable@5.1.6", "", {}, "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -237,6 +295,8 @@
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
@@ -249,6 +309,8 @@
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -267,12 +329,16 @@
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="],
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"sass": ["sass@1.100.0", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -285,10 +351,16 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"svelte": ["svelte@5.55.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-v9mFVBY1USosyIWdXE7Cg4AN0ywyKCMcAhONvli8doMowEhFhMdNLKD1j7O/UnsrdVTHaUOk/jv8hD/HClVy+g=="],
"svelte-check": ["svelte-check@4.4.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -305,6 +377,8 @@
"ultracite": ["ultracite@7.8.0", "", { "dependencies": { "@clack/prompts": "^1.4.0", "commander": "^14.0.3", "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "glob": "^13.0.6", "jsonc-parser": "^3.3.1", "nypm": "^0.6.6", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.0.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-wAIdn7YTBjygSdpz3ubMCAqja0odk2SCn/YqrM6k17D7ASouo0qaODJa76Xo3tp13yPWnNnLvcduNlmLBAtzYg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
@@ -330,5 +404,9 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
}
}
+6 -1
View File
@@ -7,6 +7,8 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^25.9.1",
"sass": "^1.100.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
@@ -27,5 +29,8 @@
},
"type": "module",
"version": "0.0.1",
"private": true
"private": true,
"dependencies": {
"bits-ui": "^2.18.1"
}
}
+10
View File
@@ -4,6 +4,16 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<script>
(() => {
const stored = localStorage.getItem("bg.theme");
const dark =
stored === "dark" ||
((stored === "system" || stored === null) &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("theme-dark");
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+32
View File
@@ -0,0 +1,32 @@
const WAVE_DURATION = 700;
export function ripple(node: HTMLElement) {
function onPointerDown(event: PointerEvent) {
if (event.button !== 0) {
return;
}
const rect = node.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
let container = node.querySelector<HTMLElement>(".ripple-container");
if (!container) {
container = document.createElement("div");
container.className = "ripple-container";
node.append(container);
}
const wave = document.createElement("div");
wave.className = "ripple-wave";
wave.style.width = `${size}px`;
wave.style.height = `${size}px`;
wave.style.left = `${event.clientX - rect.left - size / 2}px`;
wave.style.top = `${event.clientY - rect.top - size / 2}px`;
container.append(wave);
setTimeout(() => wave.remove(), WAVE_DURATION);
}
node.addEventListener("pointerdown", onPointerDown);
return {
destroy() {
node.removeEventListener("pointerdown", onPointerDown);
},
};
}
+20
View File
@@ -0,0 +1,20 @@
export function visible(node: HTMLElement, onVisible: () => void) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onVisible();
observer.disconnect();
return;
}
}
},
{ rootMargin: "300px" }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
},
};
}
+74
View File
@@ -0,0 +1,74 @@
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const RETRY_DELAY = 2500;
export type AvatarKind = "peer" | "chat";
const ready = new Map<string, string>();
const missing = new Set<string>();
const inflight = new Map<string, Promise<string | null>>();
function cacheKey(account: number, kind: AvatarKind, id: number): string {
return `${account}:${kind}:${id}`;
}
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function fetchAvatar(
account: number,
kind: AvatarKind,
id: number,
key: string,
retry: boolean
): Promise<string | null> {
const url = `${BASE}/avatars/${kind}/${id}?account_id=${account}`;
const response = await fetch(url, { headers: authHeaders() });
if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl);
return objectUrl;
}
if (response.status === 409 && retry) {
await delay(RETRY_DELAY);
return fetchAvatar(account, kind, id, key, false);
}
missing.add(key);
return null;
}
export function loadAvatar(
kind: AvatarKind,
id: number
): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
const key = cacheKey(account, kind, id);
const cached = ready.get(key);
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
if (existing) {
return existing;
}
const promise = fetchAvatar(account, kind, id, key, true).finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
+141
View File
@@ -0,0 +1,141 @@
import { goto } from "$app/navigation";
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const MAX_LIMIT = 500;
export class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.name = "ApiError";
this.status = status;
this.detail = detail;
}
}
type QueryValue = string | number | boolean | null | undefined;
interface RequestOptions {
account?: boolean;
body?: unknown;
method?: string;
query?: Record<string, QueryValue>;
}
function buildQuery(
query: Record<string, QueryValue> | undefined,
withAccount: boolean
): string {
const params = new URLSearchParams();
if (withAccount && accounts.selectedId !== null) {
params.set("account_id", String(accounts.selectedId));
}
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) {
continue;
}
const clamped =
key === "limit" && typeof value === "number"
? Math.min(value, MAX_LIMIT)
: value;
params.set(key, String(clamped));
}
}
const text = params.toString();
return text ? `?${text}` : "";
}
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
async function handleError(response: Response): Promise<never> {
if (response.status === 401) {
auth.logout();
await goto("/login");
}
let detail = response.statusText;
try {
const data = await response.json();
if (data && typeof data.detail === "string") {
detail = data.detail;
}
} catch {
detail = response.statusText;
}
throw new ApiError(response.status, detail);
}
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { method = "GET", query, body, account = false } = options;
const headers: Record<string, string> = authHeaders();
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${BASE}${path}${buildQuery(query, account)}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
return handleError(response);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export type MediaResult =
| { state: "ready"; url: string; mime: string | null }
| { state: "not-downloaded" }
| { state: "missing" };
export async function requestMedia(mediaId: number): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/${mediaId}`, {
headers: authHeaders(),
});
if (response.status === 409) {
return { state: "not-downloaded" };
}
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
state: "ready",
url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
};
}
export async function requestMediaVersion(
versionId: number
): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/version/${versionId}`, {
headers: authHeaders(),
});
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
state: "ready",
url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
};
}
+120
View File
@@ -0,0 +1,120 @@
import { request } from "$lib/api/client";
import type {
Account,
Chat,
Folder,
JobView,
MediaVersion,
MediaView,
MessageVersion,
MessageView,
PeerView,
} from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
interface Page {
limit?: number;
offset?: number;
}
export function listAccounts(): Promise<Account[]> {
return request<Account[]>("/accounts");
}
export function listChats(page: Page = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } });
}
export function listFolders(): Promise<Folder[]> {
return request<Folder[]>("/folders", { account: true });
}
export function listMessages(
chatId: number,
options: Page & { include_deleted?: boolean } = {}
): Promise<MessageView[]> {
return request<MessageView[]>(`/chats/${chatId}/messages`, {
account: true,
query: { ...options },
});
}
export function listMessageVersions(
chatId: number,
messageId: number
): Promise<MessageVersion[]> {
return request<MessageVersion[]>(
`/chats/${chatId}/messages/${messageId}/versions`,
{ account: true }
);
}
export function listDeleted(
options: Page & { chat_id?: number } = {}
): Promise<MessageView[]> {
return request<MessageView[]>("/deleted", {
account: true,
query: { ...options },
});
}
export function getPeer(peerId: number): Promise<PeerView> {
return request<PeerView>(`/peers/${peerId}`, { account: true });
}
export function getPeers(ids: number[]): Promise<PeerView[]> {
if (ids.length === 0) {
return Promise.resolve([]);
}
return request<PeerView[]>("/peers/batch", {
account: true,
query: { ids: ids.join(",") },
});
}
export function enrichChat(chatId: number): Promise<{ job_id: number }> {
return request<{ job_id: number }>(`/chats/${chatId}/enrich`, {
method: "POST",
body: { account_id: accounts.selectedId },
});
}
export function getJob(jobId: number): Promise<JobView> {
return request<JobView>(`/jobs/${jobId}`, { account: true });
}
export function getMediaVersions(
chatId: number,
messageId: number
): Promise<MediaVersion[]> {
return request<MediaVersion[]>(`/media/versions/${chatId}/${messageId}`, {
account: true,
});
}
export function getMediaMeta(mediaId: number): Promise<MediaView> {
return request<MediaView>(`/media/${mediaId}/meta`);
}
export function getMessageMedia(
chatId: number,
messageId: number
): Promise<MediaView> {
return request<MediaView>(`/media/message/${chatId}/${messageId}`, {
account: true,
});
}
export function fetchMedia(
chatId: number,
messageId: number
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/media/fetch", {
method: "POST",
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
},
});
}
+175
View File
@@ -0,0 +1,175 @@
import { requestMedia } from "$lib/api/client";
import { getMessageMedia } from "$lib/api/endpoints";
import type { MediaRef } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
export type InlineMedia =
| {
state: "ready";
mediaId: number;
kind: string;
mime: string | null;
url: string;
transcript: string | null;
}
| { state: "not-downloaded"; mediaId: number; kind: string }
| { state: "missing" };
export interface ViewerItem {
downloaded: boolean;
kind: string;
mediaId: number | null;
messageId: number;
}
export function viewerItemsFrom(
messageId: number,
media: MediaRef[]
): ViewerItem[] {
if (media.length === 0) {
return [{ messageId, mediaId: null, kind: "", downloaded: false }];
}
return media.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
}));
}
export type VisualKind = "image" | "video" | "other";
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
export function visualKind(kind: string): VisualKind {
if (kind === "photo") {
return "image";
}
if (VIDEO_KINDS.has(kind)) {
return "video";
}
return "other";
}
const ready = new Map<string, InlineMedia>();
const inflight = new Map<string, Promise<InlineMedia>>();
function cacheKey(account: number, chatId: number, messageId: number): string {
return `${account}:${chatId}:${messageId}`;
}
async function resolve(
chatId: number,
messageId: number
): Promise<InlineMedia> {
let meta: Awaited<ReturnType<typeof getMessageMedia>>;
try {
meta = await getMessageMedia(chatId, messageId);
} catch {
return { state: "missing" };
}
if (!meta.downloaded) {
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind };
}
const blob = await requestMedia(meta.id);
if (blob.state === "ready") {
return {
state: "ready",
mediaId: meta.id,
kind: meta.kind,
mime: blob.mime,
url: blob.url,
transcript: meta.extracted_text,
};
}
if (blob.state === "not-downloaded") {
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind };
}
return { state: "missing" };
}
const byId = new Map<string, InlineMedia>();
const byIdInflight = new Map<string, Promise<InlineMedia>>();
async function resolveById(media: MediaRef): Promise<InlineMedia> {
if (media.id === null) {
return { state: "missing" };
}
if (!media.downloaded) {
return { state: "not-downloaded", mediaId: media.id, kind: media.kind };
}
const blob = await requestMedia(media.id);
if (blob.state === "ready") {
return {
state: "ready",
mediaId: media.id,
kind: media.kind,
mime: blob.mime,
url: blob.url,
transcript: null,
};
}
if (blob.state === "not-downloaded") {
return { state: "not-downloaded", mediaId: media.id, kind: media.kind };
}
return { state: "missing" };
}
export function loadMediaItem(media: MediaRef): Promise<InlineMedia> {
const account = accounts.selectedId;
if (account === null || media.id === null) {
return Promise.resolve<InlineMedia>({ state: "missing" });
}
const key = `${account}:${media.id}`;
const cached = byId.get(key);
if (cached) {
return Promise.resolve(cached);
}
const existing = byIdInflight.get(key);
if (existing) {
return existing;
}
const promise = resolveById(media)
.then((result) => {
if (result.state === "ready") {
byId.set(key, result);
}
return result;
})
.finally(() => {
byIdInflight.delete(key);
});
byIdInflight.set(key, promise);
return promise;
}
export function loadInlineMedia(
chatId: number,
messageId: number
): Promise<InlineMedia> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve<InlineMedia>({ state: "missing" });
}
const key = cacheKey(account, chatId, messageId);
const cached = ready.get(key);
if (cached) {
return Promise.resolve(cached);
}
const existing = inflight.get(key);
if (existing) {
return existing;
}
const promise = resolve(chatId, messageId)
.then((result) => {
if (result.state === "ready") {
ready.set(key, result);
}
return result;
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
+375
View File
@@ -0,0 +1,375 @@
export type ChatKind = "private" | "group";
export type PolicyScopeType =
| "default_dm"
| "default_group"
| "default_channel"
| "folder"
| "chat";
export type SearchSource = "text" | "stt";
export type JobStatus =
| "pending"
| "running"
| "done"
| "failed"
| "canceled"
| "paused";
export interface Account {
account_id: number;
is_active: boolean;
label: string | null;
phone: string | null;
tg_user_id: number | null;
}
export interface Chat {
chat_id: number;
has_avatar: boolean;
is_bot: boolean;
is_broadcast: boolean;
is_contact: boolean;
kind: ChatKind;
last_date: string | null;
last_sender_id: number | null;
last_text: string | null;
message_count: number;
title: string | null;
}
export interface EntityView {
custom_emoji_id: string | null;
language: string | null;
length: number;
offset: number;
type: string;
url: string | null;
}
export interface ReplyView {
media_kind: string | null;
message_id: number | null;
sender_id: number | null;
sender_name: string | null;
text: string | null;
}
export interface ForwardView {
chat_id: number | null;
chat_title: string | null;
date: string | null;
from_id: number | null;
from_name: string | null;
kind: "channel" | "hidden" | "user";
message_id: number | null;
signature: string | null;
}
export interface MediaRef {
downloaded: boolean;
duration: number | null;
file_size: number | null;
height: number | null;
id: number | null;
kind: string;
message_id: number;
mime: string | null;
ttl_seconds: number | null;
width: number | null;
}
export interface ReactionCount {
chosen: boolean;
count: number;
custom_emoji_id: string | null;
emoji: string | null;
}
export interface InlineButton {
data: string | null;
kind: "callback" | "other" | "switch" | "url";
text: string;
url: string | null;
}
export interface WebPageView {
description: string | null;
display_url: string | null;
has_photo: boolean;
site_name: string | null;
title: string | null;
type: string | null;
url: string;
}
export interface PollOption {
correct: boolean | null;
text: string;
vote_percentage: number;
voter_count: number;
}
export interface PollView {
anonymous: boolean;
closed: boolean;
multiple: boolean;
options: PollOption[];
question: string;
quiz: boolean;
total_voter_count: number;
}
export interface ContactView {
first_name: string | null;
last_name: string | null;
phone_number: string | null;
user_id: number | null;
}
export interface LocationView {
address: string | null;
latitude: number | null;
longitude: number | null;
title: string | null;
}
export interface ServiceView {
duration: number | null;
kind: string;
member_ids: number[] | null;
pinned_message_id: number | null;
}
export interface StickerView {
emoji: string | null;
height: number | null;
is_animated: boolean;
is_video: boolean;
mime: string | null;
set_name: string | null;
width: number | null;
}
export interface MessageView {
chat_id: number;
contact: ContactView | null;
date: string;
deleted_at: string | null;
edited_at: string | null;
entities: EntityView[];
forward: ForwardView | null;
has_media: boolean;
inline_buttons: InlineButton[][];
is_animated_emoji: boolean;
is_self_destruct: boolean;
is_sticker: boolean;
location: LocationView | null;
media: MediaRef[];
media_group_id: string | null;
message_id: number;
poll: PollView | null;
quote: string | null;
reactions: ReactionCount[];
reply: ReplyView | null;
sender_id: number | null;
service: ServiceView | null;
sticker: StickerView | null;
text: string | null;
via_bot_id: number | null;
web_page: WebPageView | null;
}
export interface MessageVersion {
edit_date: string | null;
observed_at: string;
text: string | null;
}
export interface MediaVersion {
file_size: number | null;
id: number;
kind: string;
mime: string | null;
observed_at: string;
storage_key: string;
}
export interface SearchHit {
chat_id: number;
date: string;
extracted_text: string | null;
message_id: number;
sender_id: number | null;
source: SearchSource;
text: string | null;
}
export interface MediaView {
account_id: number;
chat_id: number;
created_at: string;
downloaded: boolean;
extracted_text: string | null;
file_size: number | null;
id: number;
kind: string;
message_id: number;
mime: string | null;
storage_key: string | null;
ttl_seconds: number | null;
}
export interface Callback {
data: string | null;
label: string | null;
position: number;
}
export interface Reaction {
added_at: string;
peer_id: number;
reaction: string;
removed_at: string | null;
}
export interface LinkPreview {
kind: string;
position: number;
url: string;
web_description: string | null;
web_site_name: string | null;
web_title: string | null;
web_url: string | null;
}
export interface PresenceSample {
last_online_date: string | null;
next_offline_date: string | null;
peer_id: number;
status: string;
ts: string;
}
export interface PresenceHourly {
bucket: string;
last_seen: string | null;
online_samples: number;
peer_id: number;
samples: number;
}
export interface PeerView {
first_name: string | null;
has_avatar: boolean;
is_deleted_account: boolean;
last_name: string | null;
peer_id: number;
phone: string | null;
photo_unique_id: string | null;
updated_at: string;
username: string | null;
}
export interface PeerHistoryView {
first_name: string | null;
is_deleted_account: boolean;
last_name: string | null;
observed_at: string;
phone: string | null;
photo_unique_id: string | null;
username: string | null;
}
export interface StoryView {
caption: string | null;
date: string | null;
deleted: boolean;
downloaded: boolean;
expire_date: string | null;
media_kind: string | null;
peer_id: number;
pinned: boolean;
storage_key: string | null;
story_id: number;
views: number | null;
}
export interface Annotation {
account_id: number;
chat_id: number;
created_at: string;
id: number;
message_id: number;
text: string;
updated_at: string;
}
export interface CaptureToggles {
backfill: boolean;
media: boolean;
messages: boolean;
presence: boolean;
profile_history: boolean;
reactions: boolean;
self_destruct_media: boolean;
stories: boolean;
stt: boolean;
track_edits_deletes: boolean;
}
export interface PolicyRecord extends CaptureToggles {
account_id: number | null;
id: number;
scope_id: number | null;
scope_type: PolicyScopeType;
}
export interface Folder {
bots: boolean;
broadcasts: boolean;
contacts: boolean;
exclude_ids: number[];
folder_id: number;
groups: boolean;
include_ids: number[];
is_chatlist: boolean;
non_contacts: boolean;
order_index: number;
pinned_ids: number[];
title: string;
}
export interface Watch {
account_id: number;
created_at: string;
enabled: boolean;
id: number;
kind: string;
params: Record<string, unknown>;
updated_at: string;
}
export interface Alert {
account_id: number;
created_at: string;
id: number;
payload: Record<string, unknown>;
seen: boolean;
ts: string;
watch_id: number;
}
export interface JobView {
account_id: number;
attempts: number;
created_at: string;
cursor: Record<string, unknown> | null;
error: string | null;
finished_at: string | null;
flood_waits: number;
id: number;
kind: string;
params: Record<string, unknown>;
progress: Record<string, unknown>;
started_at: string | null;
status: JobStatus;
}
@@ -0,0 +1,87 @@
<script lang="ts">
import { DropdownMenu } from "bits-ui";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
const current = $derived(accounts.selected);
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="account-trigger">
{#if current}
<Avatar
name={accountName(current)}
colorKey={current.account_id}
size={2.25}
/>
<span class="account-name">{accountName(current)}</span>
{:else}
<span class="account-name">No account</span>
{/if}
<Icon name="down" size="1.25rem" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="bg-menu-content" sideOffset={6} align="start">
{#each accounts.list as account (account.account_id)}
<DropdownMenu.Item
class="bg-menu-item"
data-selected={account.account_id === accounts.selectedId
? ""
: undefined}
onSelect={() => accounts.select(account.account_id)}
>
<Avatar
name={accountName(account)}
colorKey={account.account_id}
size={1.75}
/>
<span>{accountName(account)}</span>
{#if account.account_id === accounts.selectedId}
<Icon name="check" size="1.125rem" class="trailing" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<style lang="scss">
:global(.account-trigger) {
cursor: pointer;
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0.375rem 0.5rem;
border: 0;
border-radius: 0.625rem;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-chat-hover);
}
}
.account-name {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.bg-menu-item .trailing) {
margin-inline-start: auto;
color: var(--color-primary);
}
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import { getPeer } from "$lib/api/endpoints";
import type { PeerView } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import { peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId));
let peer = $state<PeerView | null>(null);
$effect(() => {
if (accounts.selectedId === null || !isDm) {
peer = null;
return;
}
let active = true;
peer = null;
getPeer(chatId)
.then((result) => {
if (active) {
peer = result;
}
})
.catch(() => {
if (active) {
peer = null;
}
});
return () => {
active = false;
};
});
const fallbackTitle = $derived(chat?.title ?? `Chat ${chatId}`);
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => {
if (isDm) {
if (peer?.username) {
return `@${peer.username}`;
}
return peer?.phone ?? `ID ${chatId}`;
}
const count = chat?.message_count ?? 0;
return count > 0 ? `${count} messages` : "group";
});
const avatarKind = $derived(isDm ? "peer" : "chat");
const hasAvatar = $derived(chat?.has_avatar ?? Boolean(peer?.has_avatar));
</script>
<header class="chat-header">
<Avatar
name={title}
colorKey={chatId}
size={2.5}
avatar={{ kind: avatarKind, id: chatId }}
{hasAvatar}
deleted={peer?.is_deleted_account ?? false}
/>
<div class="info">
<h2 class="title">{title}</h2>
<span class="subtitle">{subtitle}</span>
</div>
</header>
<style lang="scss">
.chat-header {
display: flex;
align-items: center;
gap: 0.625rem;
height: var(--header-height);
padding: 0 1rem;
border-bottom: 1px solid var(--color-borders);
background-color: var(--color-background);
}
.info {
overflow: hidden;
min-width: 0;
}
.title {
overflow: hidden;
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,95 @@
<script lang="ts">
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import ChatListItem from "$lib/components/ChatListItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Skeleton from "$lib/components/ui/Skeleton.svelte";
import { folderContains } from "$lib/format/folders";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { folders } from "$lib/stores/folders.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
const skeletonRows = Array.from({ length: 9 }, (_, index) => index);
const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const selectedFolder = $derived(folders.selected);
const visibleChats = $derived(
selectedFolder === null
? chats.list
: chats.list.filter((chat) => folderContains(selectedFolder, chat))
);
$effect(() => {
if (accounts.selectedId === null) {
return;
}
chats.load().catch(() => toasts.error("Failed to load chats"));
folders.load().catch(() => toasts.error("Failed to load folders"));
});
</script>
<div class="chat-list custom-scroll">
{#if chats.loading && chats.list.length === 0}
{#each skeletonRows as index (index)}
<div class="row-skeleton">
<Skeleton width="3rem" height="3rem" circle />
<div class="row-skeleton-lines">
<Skeleton width="55%" height="0.875rem" />
<Skeleton width="80%" height="0.8125rem" />
</div>
</div>
{/each}
{:else if chats.list.length === 0}
<EmptyState title="No chats yet" />
{:else}
{#key folders.selectedId}
<div
class="folder-view"
in:fly={{ x: folders.direction * 24, duration: 200, easing: cubicOut }}
>
{#if visibleChats.length === 0}
<EmptyState
title="Empty folder"
description="No chats match this folder yet"
/>
{:else}
{#each visibleChats as chat (chat.chat_id)}
<ChatListItem
{chat}
selected={chat.chat_id === activeChatId}
onclick={() => goto(`/app/${chat.chat_id}`)}
/>
{/each}
{/if}
</div>
{/key}
{/if}
</div>
<style lang="scss">
.chat-list {
overflow-y: auto;
flex: 1;
padding: 0.25rem 0.4375rem;
}
.row-skeleton {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5625rem 0.5rem;
}
.row-skeleton-lines {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.5rem;
}
</style>
@@ -0,0 +1,163 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import type { Chat } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import { formatListDate } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
interface Props {
chat: Chat;
onclick: () => void;
selected: boolean;
}
let { chat, selected, onclick }: Props = $props();
const title = $derived(chat.title ?? `Chat ${chat.chat_id}`);
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const showSender = $derived(
chat.kind === "group" && chat.last_sender_id !== null
);
$effect(() => {
if (showSender && chat.last_sender_id !== ownId) {
peers.ensure([chat.last_sender_id as number]);
}
});
const senderPrefix = $derived.by(() => {
if (!showSender) {
return "";
}
if (chat.last_sender_id === ownId) {
return "You: ";
}
const peer = peers.get(chat.last_sender_id as number);
if (!peer) {
return "";
}
return `${peer.first_name ?? peer.username ?? peer.peer_id}: `;
});
const preview = $derived(
chat.last_text ?? (chat.message_count > 0 ? "Media" : "")
);
</script>
<button
type="button"
class="Chat ListItem-button"
class:selected
use:ripple
{onclick}
>
<Avatar
name={title}
colorKey={chat.chat_id}
avatar={{ kind: avatarKind, id: chat.chat_id }}
hasAvatar={chat.has_avatar}
/>
<div class="info">
<div class="info-row">
<h3 class="title">{title}</h3>
<span class="date">{formatListDate(chat.last_date)}</span>
</div>
<div class="subtitle">
<span class="last-message">
{#if senderPrefix}
<span class="sender">{senderPrefix}</span>
{/if}
{preview}
</span>
</div>
</div>
</button>
<style lang="scss">
.Chat {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&.selected {
background-color: var(--color-chat-active);
.title,
.date,
.last-message,
.sender {
color: var(--color-white);
}
}
}
.info {
overflow: hidden;
flex: 1;
min-width: 0;
}
.info-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.title {
overflow: hidden;
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.subtitle {
margin-top: 0.125rem;
}
.last-message {
overflow: hidden;
display: block;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.sender {
color: var(--color-text);
}
</style>
@@ -0,0 +1,179 @@
<script lang="ts">
import { SvelteSet } from "svelte/reactivity";
import type { EntityView } from "$lib/api/types";
import {
buildEntityTree,
type EntityNode,
type EntityTreeNode,
jumboEmojiCount,
linkHref,
} from "$lib/format/entities";
interface Props {
entities: EntityView[];
own?: boolean;
text: string;
}
let { text, entities, own = false }: Props = $props();
const nodes = $derived(buildEntityTree(text, entities));
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
const revealed = new SvelteSet<string>();
function spoilerKey(entity: EntityView): string {
return `${entity.offset}:${entity.length}`;
}
</script>
{#snippet tree(items: EntityTreeNode[])}
{#each items as node, i (i)}
{#if node.kind === "text"}
{node.text}
{:else}
{@render entity(node)}
{/if}
{/each}
{/snippet}
{#snippet entity(node: EntityNode)}
{@const type = node.entity.type}
{#if type === "bold"}
<strong>{@render tree(node.children)}</strong>
{:else if type === "italic"}
<em>{@render tree(node.children)}</em>
{:else if type === "underline"}
<ins>{@render tree(node.children)}</ins>
{:else if type === "strikethrough"}
<del>{@render tree(node.children)}</del>
{:else if type === "spoiler"}
{@const key = spoilerKey(node.entity)}
<button
type="button"
class="spoiler"
class:revealed={revealed.has(key)}
onclick={() => revealed.add(key)}
>
{@render tree(node.children)}
</button>
{:else if type === "code"}
<code class="code" class:own>{@render tree(node.children)}</code>
{:else if type === "pre"}
<pre
class="pre"
class:own
>{#if node.entity.language}<span class="pre-lang">{node.entity.language}</span>{/if}<code>{@render tree(node.children)}</code></pre>
{:else if type === "blockquote"}
<blockquote class="blockquote">{@render tree(node.children)}</blockquote>
{:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"}
<a
class="link"
href={linkHref(node)}
target="_blank"
rel="noopener noreferrer"
>{@render tree(node.children)}</a
>
{:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"}
<span class="link">{@render tree(node.children)}</span>
{:else}
{@render tree(node.children)}
{/if}
{/snippet}
<span class="EntityText" class:jumbo={jumbo > 0} class:jumbo-1={jumbo === 1}>
{@render tree(nodes)}
</span>
<style lang="scss">
.EntityText {
overflow-wrap: anywhere;
white-space: pre-wrap;
&.jumbo {
font-size: 1.75rem;
line-height: 1.25;
}
&.jumbo-1 {
font-size: 2.5rem;
}
}
.link {
color: var(--color-links);
text-decoration: none;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
.code,
.pre {
font-family: var(--font-family-monospace, monospace);
font-size: 0.9375rem;
color: var(--color-code);
background-color: var(--color-code-bg);
&.own {
color: var(--color-code-own);
background-color: var(--color-code-own-bg);
}
}
.code {
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
}
.pre {
overflow-x: auto;
display: block;
margin: 0.125rem 0;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
white-space: pre;
}
.pre-lang {
display: block;
margin-bottom: 0.25rem;
font-size: 0.75rem;
opacity: 0.7;
}
.blockquote {
margin: 0.125rem 0;
padding: 0.125rem 0.5rem;
border-left: 0.1875rem solid var(--color-links);
border-radius: 0.25rem;
background-color: var(--color-primary-tint);
}
.spoiler {
cursor: pointer;
padding: 0;
border: none;
font: inherit;
color: transparent;
text-shadow: none;
background-color: var(--color-text);
border-radius: 0.25rem;
transition: color 0.15s, background-color 0.15s;
&.revealed {
color: inherit;
background-color: transparent;
}
}
</style>
@@ -0,0 +1,171 @@
<script lang="ts">
import { folders } from "$lib/stores/folders.svelte";
interface Tab {
id: number | null;
title: string;
}
const tabs = $derived<Tab[]>([
{ id: null, title: "All Chats" },
...folders.list.map((folder) => ({
id: folder.folder_id,
title: folder.title,
})),
]);
const activeTab = $derived(
Math.max(
0,
tabs.findIndex((tab) => tab.id === folders.selectedId)
)
);
let containerEl = $state<HTMLDivElement>();
let indicatorEl = $state<HTMLDivElement>();
let clipPath = $state("");
function updateClipPath() {
const indicator = indicatorEl;
const activeEl = indicator?.children[activeTab] as HTMLElement | undefined;
if (!(indicator && activeEl) || indicator.offsetWidth === 0) {
return;
}
const { offsetLeft, offsetWidth } = activeEl;
const width = indicator.offsetWidth;
const left = ((offsetLeft / width) * 100).toFixed(1);
const right = (
((width - (offsetLeft + offsetWidth)) / width) *
100
).toFixed(1);
clipPath = `inset(0.25rem ${right}% 0.25rem ${left}% round var(--tab-radius))`;
}
$effect(() => {
const index = activeTab;
const total = tabs.length;
updateClipPath();
const baseEl =
total > 0
? (containerEl?.children[index] as HTMLElement | undefined)
: undefined;
baseEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
$effect(() => {
if (!indicatorEl) {
return;
}
const observer = new ResizeObserver(() => updateClipPath());
observer.observe(indicatorEl);
return () => observer.disconnect();
});
</script>
<div bind:this={containerEl} class="container" class:ready={clipPath !== ""}>
{#each tabs as tab (tab.id)}
<button type="button" class="tab" onclick={() => folders.select(tab.id)}>
{tab.title}
</button>
{/each}
<div
bind:this={indicatorEl}
class="active-indicator"
style={clipPath ? `clip-path: ${clipPath}` : undefined}
aria-hidden="true"
>
{#each tabs as tab (tab.id)}
<button type="button" class="tab" tabindex="-1">{tab.title}</button>
{/each}
</div>
</div>
<style lang="scss">
.container,
.active-indicator {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
padding-block: 0.375rem;
padding-inline: 0.25rem;
}
.container {
--tab-radius: 1.25rem;
user-select: none;
scrollbar-width: none;
position: relative;
overflow-x: auto;
border-radius: 1.5rem;
opacity: 0;
background-color: var(--color-background);
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
transition: opacity 150ms;
&::-webkit-scrollbar {
display: none;
}
&.ready {
opacity: 1;
}
}
.active-indicator {
pointer-events: none;
will-change: clip-path;
isolation: isolate;
position: absolute;
z-index: 10;
inset: 0;
contain: layout style paint;
overflow: hidden;
width: fit-content;
background-color: var(--color-primary-opacity);
transition: clip-path var(--slide-transition);
}
.tab {
cursor: var(--custom-cursor, pointer);
display: flex;
flex-shrink: 0;
gap: 0.25rem;
align-items: center;
padding: 0.375rem 1rem;
border: none;
border-radius: var(--tab-radius);
font-family: inherit;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
white-space: nowrap;
appearance: none;
background: none;
&:hover {
opacity: 0.85;
}
.active-indicator & {
color: var(--color-primary);
}
}
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { ForwardView } from "$lib/api/types";
import { peerName } from "$lib/format/peer";
import { peers } from "$lib/stores/peers.svelte";
interface Props {
forward: ForwardView;
}
let { forward }: Props = $props();
const peer = $derived(
forward.from_id === null ? undefined : peers.get(forward.from_id)
);
const name = $derived.by(() => {
if (forward.kind === "channel") {
return forward.chat_title ?? "Channel";
}
if (forward.kind === "hidden") {
return forward.from_name ?? "Hidden account";
}
if (peer) {
return peerName(peer);
}
return forward.from_name ?? "Unknown";
});
</script>
<div class="ForwardHeader">
<span class="label">Forwarded from</span>
<span class="name">{name}</span>
{#if forward.signature}
<span class="signature">({forward.signature})</span>
{/if}
</div>
<style lang="scss">
.ForwardHeader {
overflow: hidden;
margin-bottom: 0.1875rem;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.label {
color: var(--color-text-secondary);
}
.name {
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.signature {
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
let value = $state("");
let busy = $state(false);
async function submit(event: SubmitEvent) {
event.preventDefault();
if (!value.trim() || busy) {
return;
}
busy = true;
auth.login(value.trim());
try {
await accounts.load();
await goto("/app");
} catch {
toasts.error("Invalid token");
} finally {
busy = false;
}
}
</script>
<div class="login">
<form class="login-card" onsubmit={submit}>
<div class="logo">
<Icon name="lock" size="2.75rem" />
</div>
<h1>Beavergram</h1>
<p class="subtitle">Enter your access token to continue.</p>
<input
class="form-control"
type="password"
placeholder="Access token"
autocomplete="current-password"
bind:value
>
<Button type="submit" loading={busy} disabled={busy || !value.trim()}>
Log in
</Button>
</form>
</div>
<style lang="scss">
.login {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 1.5rem;
background-color: var(--color-background-secondary);
}
.login-card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 24rem;
padding: 2.5rem 2rem;
border-radius: var(--border-radius-modal);
background-color: var(--color-background);
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
}
.logo {
display: flex;
align-items: center;
justify-content: center;
width: 6rem;
height: 6rem;
margin-bottom: 1.5rem;
border-radius: 50%;
color: var(--color-white);
background-image: linear-gradient(var(--color-primary), var(--color-primary-shade));
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.subtitle {
margin: 0 0 1.75rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
}
.form-control {
width: 100%;
height: 3.25rem;
margin-bottom: 1.25rem;
padding: 0 1rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default-small);
font-size: 1rem;
color: var(--color-text);
background-color: transparent;
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: var(--color-primary);
}
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import type { MediaRef } from "$lib/api/types";
import AlbumTile from "$lib/components/media/AlbumTile.svelte";
interface Props {
chatId: number;
media: MediaRef[];
onopen: (index: number) => void;
}
let { media, chatId, onopen }: Props = $props();
const columns = $derived.by(() => {
const count = media.length;
if (count === 2) {
return 2;
}
if (count === 4) {
return 2;
}
return 3;
});
</script>
<div class="MediaAlbum" style:--cols={columns}>
{#each media as item, index (item.id ?? index)}
<AlbumTile media={item} {chatId} onopen={() => onopen(index)} />
{/each}
</div>
<style lang="scss">
.MediaAlbum {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 0.125rem;
overflow: hidden;
max-width: 20rem;
margin-bottom: 0.25rem;
border-radius: var(--border-radius-default-small);
}
</style>
@@ -0,0 +1,117 @@
<script lang="ts">
import { type MediaResult, requestMediaVersion } from "$lib/api/client";
import { visualKind } from "$lib/api/media";
import type { MediaVersion } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
interface Props {
version: MediaVersion;
}
let { version }: Props = $props();
let result = $state<MediaResult | null>(null);
const vk = $derived(visualKind(version.kind));
$effect(() => {
let active = true;
requestMediaVersion(version.id).then((value) => {
if (active) {
result = value;
}
});
return () => {
active = false;
};
});
</script>
<div class="thumb">
{#if result === null}
<div class="placeholder"><Spinner /></div>
{:else if result.state === "ready" && vk === "image"}
<a href={result.url} target="_blank" rel="noopener">
<img src={result.url} alt={version.kind}>
</a>
{:else if result.state === "ready" && vk === "video"}
<a href={result.url} target="_blank" rel="noopener">
<video src={result.url} muted preload="metadata"></video>
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
</a>
{:else if result.state === "ready"}
<a class="file" href={result.url} target="_blank" rel="noopener">
<Icon name="document" size="1.125rem" />
<span>{version.kind}</span>
</a>
{:else}
<div class="placeholder missing">
<Icon name="no-download" size="1.25rem" />
</div>
{/if}
</div>
<style lang="scss">
.thumb {
flex-shrink: 0;
}
a {
position: relative;
display: block;
overflow: hidden;
border-radius: var(--border-radius-default-small);
}
img,
video {
display: block;
width: 7rem;
height: 7rem;
object-fit: cover;
background-color: var(--color-default-shadow);
}
.play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-white);
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
}
.file {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
color: var(--color-primary);
text-decoration: none;
background-color: var(--color-primary-tint);
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 7rem;
height: 7rem;
border-radius: var(--border-radius-default-small);
color: var(--color-text-secondary);
background-color: var(--color-default-shadow);
&.missing {
width: auto;
height: auto;
padding: 0.75rem;
}
}
</style>
@@ -0,0 +1,317 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "$lib/api/client";
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
import type { ViewerItem } from "$lib/api/media";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId: number;
index: number;
items: ViewerItem[];
open: boolean;
}
let {
open = $bindable(),
index = $bindable(),
chatId,
items,
}: Props = $props();
let kind = $state("");
let messageId = $state<number | null>(null);
let result = $state<MediaResult | null>(null);
let loading = $state(false);
let token = 0;
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
const isImage = $derived(mime.startsWith("image/") || kind === "photo");
const isVideo = $derived(
mime.startsWith("video/") || kind === "video" || kind === "video_note"
);
const isAudio = $derived(
mime.startsWith("audio/") || kind === "voice" || kind === "audio"
);
const hasNav = $derived(items.length > 1);
function revoke() {
if (result?.state === "ready") {
URL.revokeObjectURL(result.url);
}
}
async function load(item: ViewerItem) {
revoke();
loading = true;
result = null;
kind = item.kind;
messageId = item.messageId;
const current = ++token;
try {
let mediaId = item.mediaId;
let downloaded = item.downloaded;
if (mediaId === null) {
const meta = await getMessageMedia(chatId, item.messageId);
mediaId = meta.id;
downloaded = meta.downloaded;
kind = meta.kind;
}
const next = downloaded
? await requestMedia(mediaId)
: ({ state: "not-downloaded" } as MediaResult);
if (current === token) {
result = next;
}
} catch {
if (current === token) {
toasts.error("Failed to load media");
}
} finally {
if (current === token) {
loading = false;
}
}
}
async function queueFetch() {
if (messageId === null) {
return;
}
try {
await fetchMedia(chatId, messageId);
toasts.success("Download queued");
} catch {
toasts.error("Failed to queue download");
}
}
function step(delta: number) {
const next = index + delta;
if (next >= 0 && next < items.length) {
index = next;
}
}
function onkeydown(event: KeyboardEvent) {
if (!(open && hasNav)) {
return;
}
if (event.key === "ArrowLeft") {
step(-1);
} else if (event.key === "ArrowRight") {
step(1);
}
}
$effect(() => {
const item = items[index];
const isOpen = open;
untrack(() => {
if (isOpen && item) {
load(item);
}
});
});
$effect(() => {
if (!open) {
untrack(() => {
revoke();
result = null;
});
}
});
</script>
<svelte:window {onkeydown} />
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content">
<Dialog.Title class="media-title">{kind || "Media"}</Dialog.Title>
<Dialog.Close class="media-close" aria-label="Close">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
{#if hasNav}
<span class="media-counter">{index + 1} / {items.length}</span>
{/if}
<div class="media-body">
{#if loading}
<Spinner color="white" />
{:else if result?.state === "ready" && isImage}
<img class="media-image" src={result.url} alt={kind}>
{:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<video class="media-video" src={result.url} controls></video>
{:else if result?.state === "ready" && isAudio}
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<audio src={result.url} controls></audio>
{:else if result?.state === "ready"}
<a class="media-download" href={result.url} download>
<Icon name="download" />
Download file
</a>
{:else if result?.state === "not-downloaded"}
<div class="media-message">
<p>This media has not been downloaded yet.</p>
<Button variant="primary" fluid onclick={queueFetch}>
Fetch media
</Button>
</div>
{:else if result?.state === "missing"}
<p class="media-message">Media not found.</p>
{/if}
</div>
{#if hasNav}
<button
class="media-nav prev"
type="button"
aria-label="Previous"
disabled={index === 0}
onclick={() => step(-1)}
>
<Icon name="previous" size="1.75rem" />
</button>
<button
class="media-nav next"
type="button"
aria-label="Next"
disabled={index === items.length - 1}
onclick={() => step(1)}
>
<Icon name="next" size="1.75rem" />
</button>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
:global(.media-overlay) {
position: fixed;
inset: 0;
z-index: var(--z-media-viewer);
background-color: rgba(0, 0, 0, 0.9);
}
:global(.media-content) {
position: fixed;
inset: 0;
z-index: var(--z-media-viewer);
display: flex;
flex-direction: column;
outline: none;
}
:global(.media-title) {
position: absolute;
top: 1rem;
left: 1.25rem;
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
text-transform: capitalize;
}
.media-counter {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.9375rem;
color: var(--color-white);
}
:global(.media-close) {
cursor: pointer;
position: absolute;
top: 0.75rem;
right: 1rem;
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-body {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
}
.media-image,
.media-video {
max-width: 90vw;
max-height: 85vh;
border-radius: var(--border-radius-default);
}
.media-message {
max-width: 22rem;
color: var(--color-white);
text-align: center;
}
.media-download {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-white);
text-decoration: none;
}
.media-nav {
cursor: pointer;
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--color-white);
background-color: rgba(255, 255, 255, 0.1);
&:disabled {
cursor: default;
opacity: 0.3;
}
&.prev {
left: 1.25rem;
}
&.next {
right: 1.25rem;
}
}
</style>
@@ -0,0 +1,262 @@
<script lang="ts">
import type { MessageView } from "$lib/api/types";
import EntityText from "$lib/components/EntityText.svelte";
import ForwardHeader from "$lib/components/ForwardHeader.svelte";
import MediaAlbum from "$lib/components/MediaAlbum.svelte";
import MessageMedia from "$lib/components/MessageMedia.svelte";
import MessageMeta from "$lib/components/MessageMeta.svelte";
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { appear } from "$lib/transitions/appear";
interface Props {
animate: boolean;
firstInGroup: boolean;
highlighted: boolean;
isGroupChat: boolean;
lastInGroup: boolean;
message: MessageView;
onjump: (messageId: number) => void;
onmedia: (index: number) => void;
onversions: () => void;
own: boolean;
}
let {
message,
own,
isGroupChat,
firstInGroup,
lastInGroup,
highlighted,
animate,
onjump,
onmedia,
onversions,
}: Props = $props();
const deleted = $derived(message.deleted_at !== null);
const hasText = $derived(Boolean(message.text));
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const sender = $derived(
message.sender_id === null ? undefined : peers.get(message.sender_id)
);
const senderName = $derived.by(() => {
if (own) {
return accounts.selected ? accountName(accounts.selected) : "You";
}
if (sender) {
return peerName(sender);
}
return message.sender_id === null ? "" : String(message.sender_id);
});
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
const showName = $derived(isGroupChat && !own && firstInGroup);
const avatarId = $derived(own ? ownId : message.sender_id);
const avatarHas = $derived(
own
? ownId !== null && peers.get(ownId)?.has_avatar
: (sender?.has_avatar ?? false)
);
</script>
<div
class="Message"
class:own
class:deleted
class:highlighted
class:first-in-group={firstInGroup}
class:last-in-group={lastInGroup}
class:with-avatar={isGroupChat}
data-message-id={message.message_id}
in:appear={{ disabled: !animate }}
>
{#if isGroupChat}
<div class="avatar-slot">
{#if lastInGroup && avatarId !== null}
<Avatar
name={senderName}
colorKey={avatarId}
size={2.125}
avatar={{ kind: "peer", id: avatarId }}
hasAvatar={avatarHas}
/>
{/if}
</div>
{/if}
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
{/if}
{#if message.reply}
<ReplyHeader reply={message.reply} {onjump} />
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{/if}
{#if message.media.length > 1}
<MediaAlbum
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
{/if}
{#if hasText}
<div class="text">
<EntityText
text={message.text ?? ""}
entities={message.entities}
{own}
/>
</div>
{:else if !(message.has_media || deleted)}
<div class="text empty">(no text)</div>
{/if}
<MessageMeta {message} {onversions} />
{#if lastInGroup}
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
<path
class="corner"
d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z"
/>
</svg>
{/if}
</div>
</div>
<style lang="scss">
.Message {
--background-color: var(--color-background);
--meta-color: var(--color-text-meta);
position: relative;
transform-origin: bottom left;
display: flex;
align-items: flex-end;
gap: 0.4375rem;
margin-bottom: 0.125rem;
&.last-in-group {
margin-bottom: 0.5rem;
}
&.own {
--background-color: var(--color-background-own);
--meta-color: var(--color-message-meta-own);
}
}
.avatar-slot {
flex-shrink: 0;
width: 2.125rem;
}
.message-content {
position: relative;
max-width: min(30rem, 75%);
min-width: 3.5rem;
padding: 0.3125rem 0.5rem 0.375rem;
border-radius: var(--border-radius-messages);
font-size: 1rem;
line-height: 1.3125;
color: var(--color-text);
background-color: var(--background-color);
box-shadow: 0 1px 2px var(--color-default-shadow);
}
.Message.highlighted .message-content {
animation: highlight-flash 1.6s ease;
}
@keyframes highlight-flash {
0%,
60% {
background-color: var(--color-primary-opacity);
}
100% {
background-color: var(--background-color);
}
}
.Message:not(.first-in-group) .message-content {
border-top-left-radius: var(--border-radius-messages-small);
}
.Message:not(.last-in-group) .message-content {
border-bottom-left-radius: var(--border-radius-messages-small);
}
.Message.last-in-group .message-content.has-appendix {
border-bottom-left-radius: 0;
}
.sender-name {
overflow: hidden;
margin-bottom: 0.0625rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.text {
overflow-wrap: anywhere;
white-space: pre-wrap;
&.empty {
font-style: italic;
color: var(--color-text-secondary);
}
}
.deleted .text {
color: var(--color-text-secondary);
text-decoration: line-through;
}
.deleted-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
}
.svg-appendix {
position: absolute;
bottom: -0.0625rem;
left: -0.5rem;
width: 9px;
height: 20px;
}
.svg-appendix .corner {
fill: var(--background-color);
}
</style>
@@ -0,0 +1,353 @@
<script lang="ts">
import { tick } from "svelte";
import { listMessages } from "$lib/api/endpoints";
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
import type { MessageView } from "$lib/api/types";
import MediaViewer from "$lib/components/MediaViewer.svelte";
import MessageBubble from "$lib/components/MessageBubble.svelte";
import MessageVersions from "$lib/components/MessageVersions.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatDay } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const skeletonBubbles = [42, 66, 28, 54, 38, 72, 46];
const PAGE = 60;
const SCROLL_THRESHOLD = 160;
const STICK_OFFSET = 9;
const IDLE_DELAY = 1500;
let messages = $state<MessageView[]>([]);
let loading = $state(true);
let loadingOlder = $state(false);
let hasMore = $state(true);
let container = $state<HTMLDivElement | null>(null);
let suppressAppear = $state(false);
let scrolling = $state(false);
let stuckDay = $state<string | null>(null);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false);
let mediaItems = $state<ViewerItem[]>([]);
let mediaIndex = $state(0);
let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const isGroupChat = $derived(chatId < 0);
interface Row {
dayKey: string;
daySeparator: string | null;
firstInGroup: boolean;
lastInGroup: boolean;
message: MessageView;
own: boolean;
}
function dayKey(iso: string): string {
return new Date(iso).toDateString();
}
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const prev = messages[i - 1];
const next = messages[i + 1];
const day = dayKey(message.date);
const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({
message,
dayKey: day,
own: ownId !== null && message.sender_id === ownId,
firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay,
daySeparator: samePrevDay ? null : formatDay(message.date),
});
}
return out;
});
function ensurePeers(items: MessageView[]) {
const ids = new Set<number>();
for (const message of items) {
if (message.sender_id !== null) {
ids.add(message.sender_id);
}
if (message.reply?.sender_id != null) {
ids.add(message.reply.sender_id);
}
if (message.forward?.from_id != null) {
ids.add(message.forward.from_id);
}
}
if (ownId !== null) {
ids.add(ownId);
}
peers.ensure(ids);
}
function jumpToMessage(messageId: number) {
const target = container?.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`
);
if (!target) {
toasts.error("Message not loaded");
return;
}
target.scrollIntoView({ behavior: "smooth", block: "center" });
highlightId = messageId;
if (highlightTimer) {
clearTimeout(highlightTimer);
}
highlightTimer = setTimeout(() => {
highlightId = null;
}, 1600);
}
function scrollToBottom() {
if (container) {
container.scrollTop = container.scrollHeight;
}
}
function updateStuck() {
if (!container) {
return;
}
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
let day: string | null = null;
for (const sep of seps) {
if (sep.getBoundingClientRect().top <= limit) {
day = sep.dataset.day ?? null;
}
}
stuckDay = day;
}
async function loadInitial() {
loading = true;
hasMore = true;
try {
const page = await listMessages(chatId, {
limit: PAGE,
offset: 0,
include_deleted: true,
});
messages = [...page].reverse();
hasMore = page.length === PAGE;
ensurePeers(messages);
await tick();
scrollToBottom();
updateStuck();
} catch {
toasts.error("Failed to load messages");
} finally {
loading = false;
}
}
async function loadOlder() {
if (loadingOlder || !hasMore || container === null) {
return;
}
loadingOlder = true;
suppressAppear = true;
const el = container;
const prevHeight = el.scrollHeight;
const prevTop = el.scrollTop;
try {
const page = await listMessages(chatId, {
limit: PAGE,
offset: messages.length,
include_deleted: true,
});
if (page.length > 0) {
messages = [...[...page].reverse(), ...messages];
ensurePeers(page);
}
hasMore = page.length === PAGE;
await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
} finally {
loadingOlder = false;
suppressAppear = false;
}
}
function onScroll() {
scrolling = true;
if (idleTimer) {
clearTimeout(idleTimer);
}
idleTimer = setTimeout(() => {
scrolling = false;
}, IDLE_DELAY);
updateStuck();
if (container && container.scrollTop < SCROLL_THRESHOLD) {
loadOlder();
}
}
function openMedia(message: MessageView, index: number) {
mediaItems = viewerItemsFrom(message.message_id, message.media);
mediaIndex = index;
mediaOpen = true;
}
function openVersions(messageId: number) {
versionsMessageId = messageId;
versionsOpen = true;
}
$effect(() => {
const deps = {
account: accounts.selectedId,
revision: chats.revision,
};
if (deps.account === null) {
return;
}
loadInitial();
});
</script>
<div
bind:this={container}
class="message-list custom-scroll"
onscroll={onScroll}
>
{#if loading && messages.length === 0}
<div class="messages-container">
{#each skeletonBubbles as width, index (index)}
<div class="bubble-skeleton skeleton" style:width="{width}%"></div>
{/each}
</div>
{:else if rows.length === 0}
<EmptyState
title="No messages"
description="This chat has no archived messages"
/>
{:else}
<div class="messages-container">
{#if loadingOlder}
<div class="loading-older"><Spinner /></div>
{/if}
{#each rows as row (row.message.message_id)}
{#if row.daySeparator}
<div
class="day-separator"
class:idle={!scrolling && row.dayKey === stuckDay}
data-day={row.dayKey}
>
<span>{row.daySeparator}</span>
</div>
{/if}
<MessageBubble
message={row.message}
own={row.own}
{isGroupChat}
firstInGroup={row.firstInGroup}
lastInGroup={row.lastInGroup}
highlighted={highlightId === row.message.message_id}
animate={!suppressAppear}
onjump={jumpToMessage}
onmedia={(index) => openMedia(row.message, index)}
onversions={() => openVersions(row.message.message_id)}
/>
{/each}
</div>
{/if}
</div>
<MediaViewer
bind:open={mediaOpen}
bind:index={mediaIndex}
{chatId}
items={mediaItems}
/>
<MessageVersions
bind:open={versionsOpen}
{chatId}
messageId={versionsMessageId}
/>
<style lang="scss">
.message-list {
overflow-y: auto;
height: 100%;
}
.messages-container {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
padding: 1rem max(1rem, calc((100% - var(--messages-container-width)) / 2));
}
.loading-older {
display: flex;
justify-content: center;
padding: 0.5rem 0;
}
.bubble-skeleton {
align-self: flex-start;
height: 2.25rem;
margin-bottom: 0.5rem;
border-radius: var(--border-radius-messages);
&:last-child {
margin-bottom: 0;
}
}
.day-separator {
pointer-events: none;
position: sticky;
top: 0.5625rem;
z-index: var(--z-sticky-date);
display: flex;
justify-content: center;
margin: 0.75rem 0;
span {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
background-color: var(--color-default-shadow);
backdrop-filter: blur(8px);
transition: opacity 0.3s ease;
}
&.idle span {
opacity: 0;
}
}
</style>
@@ -0,0 +1,321 @@
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { fetchMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
loadInlineMedia,
visualKind,
} from "$lib/api/media";
import type { MessageView } from "$lib/api/types";
import AudioFile from "$lib/components/media/AudioFile.svelte";
import VideoNote from "$lib/components/media/VideoNote.svelte";
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
message: MessageView;
onopen: () => void;
own: boolean;
}
let { message, onopen, own }: Props = $props();
const POLL_TRIES = 5;
const POLL_DELAY = 3000;
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
const ready = $derived(media?.state === "ready" ? media : null);
const kind = $derived(ready?.kind ?? "");
const mime = $derived(ready?.mime ?? "");
const isImage = $derived(kind === "photo");
const isStaticSticker = $derived(
kind === "sticker" && mime.startsWith("image/")
);
const isVideoSticker = $derived(
kind === "sticker" && mime.startsWith("video/")
);
const isTgsSticker = $derived(
kind === "sticker" && mime === "application/x-tgsticker"
);
const isAnimation = $derived(kind === "animation" || kind === "gif");
const isThumbVideo = $derived(kind === "video");
const vk = $derived(
media && media.state !== "missing" ? visualKind(media.kind) : "other"
);
const label = $derived(
media && media.state !== "missing" ? media.kind : "media"
);
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function start() {
media = await loadInlineMedia(message.chat_id, message.message_id);
loaded = true;
}
async function poll() {
for (let i = 0; i < POLL_TRIES; i++) {
await delay(POLL_DELAY);
const next = await loadInlineMedia(message.chat_id, message.message_id);
if (next.state === "ready") {
media = next;
return;
}
}
}
async function queue() {
if (queuing) {
return;
}
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Download queued");
poll();
} catch {
toasts.error("Failed to queue download");
} finally {
queuing = false;
}
}
</script>
<div class="message-media" use:visible={start}>
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Self-destruct media</span>
</button>
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"}
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
{:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} />
{:else if ready && kind === "audio"}
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment">
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img src={ready.url} alt="sticker">
</button>
{:else if ready && isVideoSticker}
<video
class="media-sticker-video"
src={ready.url}
autoplay
loop
muted
playsinline
></video>
{:else if ready && isTgsSticker}
<div class="media-sticker tgs">
<span class="tgs-emoji">{message.sticker?.emoji ?? "🎞"}</span>
</div>
{:else if ready && isAnimation}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} autoplay loop muted playsinline></video>
<span class="gif-badge">GIF</span>
</button>
{:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} muted preload="metadata"></video>
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
</button>
{:else if ready}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="document" size="1.25rem" />
<span>{label}</span>
</button>
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Video" : "Photo"}</span>
<small>{queuing ? "Queued" : "Tap to download"}</small>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "Queued" : `Download ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Media</span>
</button>
{/if}
</div>
<style lang="scss">
.message-media {
margin-bottom: 0.25rem;
}
.media-thumb {
cursor: pointer;
position: relative;
display: block;
overflow: hidden;
max-width: 100%;
padding: 0;
border: 0;
border-radius: var(--border-radius-default-small);
background-color: var(--color-default-shadow);
img,
video {
display: block;
width: 100%;
max-width: 20rem;
max-height: 20rem;
object-fit: cover;
}
}
.media-sticker {
cursor: pointer;
display: block;
padding: 0;
border: 0;
background: transparent;
img {
display: block;
width: 12rem;
height: 12rem;
object-fit: contain;
}
}
.media-sticker-video {
display: block;
width: 12rem;
height: 12rem;
object-fit: contain;
}
.tgs {
display: flex;
align-items: center;
justify-content: center;
width: 8rem;
height: 8rem;
border-radius: var(--border-radius-default-small);
background-color: var(--color-primary-tint);
}
.tgs-emoji {
font-size: 3.5rem;
line-height: 1;
}
.gif-badge {
position: absolute;
top: 0.375rem;
left: 0.375rem;
padding: 0.0625rem 0.3125rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
background-color: rgba(0, 0, 0, 0.45);
}
.play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
.media-skeleton {
display: flex;
align-items: center;
justify-content: center;
width: 12rem;
height: 9rem;
border-radius: var(--border-radius-default-small);
background-color: var(--color-default-shadow);
}
.media-placeholder {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
width: 12rem;
height: 9rem;
border: 0;
border-radius: var(--border-radius-default-small);
color: var(--color-primary);
text-align: center;
background-color: var(--color-primary-tint);
small {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
}
.media-chip {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: 0;
border-radius: var(--border-radius-default-small);
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background-color: var(--color-primary-tint);
&.self-destruct {
color: var(--color-orange);
background-color: var(--color-light-coral);
}
}
</style>
@@ -0,0 +1,55 @@
<script lang="ts">
import type { MessageView } from "$lib/api/types";
import { formatTime } from "$lib/format/datetime";
interface Props {
message: MessageView;
onversions?: () => void;
}
let { message, onversions }: Props = $props();
</script>
<span class="MessageMeta">
{#if message.edited_at}
<button
type="button"
class="edited"
title="View edit history"
onclick={onversions}
>
edited
</button>
{/if}
<span class="time">{formatTime(message.date)}</span>
</span>
<style lang="scss">
.MessageMeta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
float: right;
margin-top: 0.125rem;
margin-inline-start: 0.5rem;
font-size: 0.75rem;
color: var(--meta-color, var(--color-text-meta));
user-select: none;
}
.time {
white-space: nowrap;
}
.edited {
cursor: pointer;
padding: 0;
border: 0;
font-size: 0.75rem;
font-style: italic;
color: inherit;
background: transparent;
}
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { getMediaVersions, listMessageVersions } from "$lib/api/endpoints";
import type { MediaVersion, MessageVersion } from "$lib/api/types";
import MediaVersionThumb from "$lib/components/MediaVersionThumb.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId: number;
messageId: number | null;
open: boolean;
}
let { open = $bindable(), chatId, messageId }: Props = $props();
let versions = $state<MessageVersion[]>([]);
let mediaVersions = $state<MediaVersion[]>([]);
let loading = $state(false);
$effect(() => {
if (open && messageId !== null) {
loading = true;
mediaVersions = [];
const id = messageId;
Promise.all([
listMessageVersions(chatId, id),
getMediaVersions(chatId, id),
])
.then(([text, media]) => {
versions = text;
mediaVersions = media;
})
.catch(() => toasts.error("Failed to load edit history"))
.finally(() => {
loading = false;
});
}
});
</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">Edit history</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="versions">
{#if loading}
<div class="centered"><Spinner /></div>
{:else if versions.length === 0 && mediaVersions.length === 0}
<p class="empty">No versions recorded.</p>
{:else}
{#if mediaVersions.length > 0}
<div class="media-versions">
<span class="media-label">Media versions</span>
<div class="media-strip">
{#each mediaVersions as media (media.id)}
<MediaVersionThumb version={media} />
{/each}
</div>
</div>
{/if}
{#each versions as version, index (version.observed_at)}
<div class="version">
<div class="version-meta">
<span class="version-label">Version {index + 1}</span>
<span class="version-date"
>{formatFull(version.observed_at)}</span
>
</div>
<div class="version-text">{version.text ?? "(no text)"}</div>
</div>
{/each}
{/if}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
:global(.dialog-overlay) {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background-color: rgba(0, 0, 0, 0.5);
}
:global(.dialog-content) {
position: fixed;
z-index: var(--z-modal);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
width: min(32rem, 92vw);
max-height: 80vh;
border-radius: var(--border-radius-default);
background-color: var(--color-background);
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
outline: none;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-borders);
}
:global(.dialog-title) {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
:global(.dialog-close) {
cursor: pointer;
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.versions {
overflow-y: auto;
padding: 0.75rem 1.25rem 1.25rem;
}
.media-versions {
padding-bottom: 0.75rem;
margin-bottom: 0.25rem;
border-bottom: 1px solid var(--color-borders);
}
.media-label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.media-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.version {
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-borders);
&:last-child {
border-bottom: 0;
}
}
.version-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.version-label {
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.version-date {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.version-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.centered {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.empty {
padding: 1rem 0;
color: var(--color-text-secondary);
text-align: center;
}
</style>
@@ -0,0 +1,110 @@
<script lang="ts">
import type { ReplyView } from "$lib/api/types";
import { mediaKindLabel } from "$lib/format/media";
import { peerColorIndex, peerName } from "$lib/format/peer";
import { peers } from "$lib/stores/peers.svelte";
interface Props {
onjump?: (messageId: number) => void;
reply: ReplyView;
}
let { reply, onjump }: Props = $props();
const sender = $derived(
reply.sender_id === null ? undefined : peers.get(reply.sender_id)
);
const name = $derived(
sender
? peerName(sender)
: (reply.sender_name ?? (reply.sender_id ? String(reply.sender_id) : ""))
);
const colorIndex = $derived(peerColorIndex(reply.sender_id ?? 0));
const preview = $derived(
reply.text || mediaKindLabel(reply.media_kind) || ""
);
const mediaOnly = $derived(!reply.text && reply.media_kind !== null);
const canJump = $derived(reply.message_id !== null && onjump !== undefined);
function jump() {
if (reply.message_id !== null) {
onjump?.(reply.message_id);
}
}
</script>
<button
type="button"
class="ReplyHeader peer-color-{colorIndex}"
class:clickable={canJump}
disabled={!canJump}
onclick={jump}
>
<span class="bar"></span>
<span class="body">
<span class="title">{name || "Reply"}</span>
<span class="preview" class:media={mediaOnly}>{preview}</span>
</span>
</button>
<style lang="scss">
.ReplyHeader {
overflow: hidden;
display: flex;
align-items: stretch;
width: 100%;
margin-bottom: 0.1875rem;
padding: 0;
border: none;
border-radius: 0.25rem;
text-align: left;
background-color: var(--color-primary-tint);
&.clickable {
cursor: pointer;
}
&.clickable:hover {
background-color: var(--color-primary-opacity);
}
}
.bar {
flex-shrink: 0;
width: 0.1875rem;
background-color: currentColor;
}
.body {
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0.0625rem 0.375rem;
}
.title {
overflow: hidden;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: currentColor;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview {
overflow: hidden;
font-size: 0.875rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
&.media {
color: var(--color-text-secondary);
}
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { fetchMedia } from "$lib/api/endpoints";
import { type InlineMedia, loadMediaItem, visualKind } from "$lib/api/media";
import type { MediaRef } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId: number;
media: MediaRef;
onopen: () => void;
}
let { media, chatId, onopen }: Props = $props();
let loaded = $state(false);
let inline = $state<InlineMedia | null>(null);
let queuing = $state(false);
const ready = $derived(inline?.state === "ready" ? inline : null);
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
async function start() {
inline = await loadMediaItem(media);
loaded = true;
}
async function queue() {
if (queuing) {
return;
}
queuing = true;
try {
await fetchMedia(chatId, media.message_id);
toasts.success("Download queued");
} catch {
toasts.error("Failed to queue download");
} finally {
queuing = false;
}
}
</script>
<div class="AlbumTile" use:visible={start}>
{#if ready && isVideo}
<button class="tile" onclick={onopen} type="button">
<video src={ready.url} muted preload="metadata"></video>
<span class="play"><Icon name="large-play" size="2rem" /></span>
</button>
{:else if ready}
<button class="tile" onclick={onopen} type="button">
<img src={ready.url} alt="attachment">
</button>
{:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
</button>
{:else if loaded}
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
{:else}
<div class="tile placeholder"><Spinner /></div>
{/if}
</div>
<style lang="scss">
.AlbumTile {
aspect-ratio: 1;
min-width: 0;
}
.tile {
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background-color: var(--color-default-shadow);
img,
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
color: var(--color-primary);
background-color: var(--color-primary-tint);
}
.play {
position: absolute;
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
</style>
@@ -0,0 +1,153 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
interface Props {
own: boolean;
title: string;
url: string;
}
let { url, title, own }: Props = $props();
let element = $state<HTMLAudioElement>();
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0);
function toggle() {
if (!element) {
return;
}
if (paused) {
element.play().catch(() => undefined);
} else {
element.pause();
}
}
function seek(event: PointerEvent) {
if (!(element && duration > 0)) {
return;
}
const rect = event.currentTarget as HTMLElement;
const ratio =
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
}
</script>
<div class="Audio" class:own>
<button class="toggle" onclick={toggle} type="button" aria-label="Play audio">
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
</button>
<div class="content">
<div class="title">{title}</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="seekline" onpointerdown={seek}>
<div class="track"></div>
<div class="fill" style:width={`${Math.round(progress * 100)}%`}></div>
</div>
<div class="meta">
{formatDuration(currentTime)}
/ {formatDuration(duration)}
</div>
</div>
<!-- biome-ignore lint/a11y/useMediaCaption: archived audio track has no captions -->
<audio
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
preload="metadata"
src={url}
></audio>
</div>
<style lang="scss">
.Audio {
--active: var(--color-primary);
--toggle-fg: var(--color-white);
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 15rem;
padding: 0.1875rem 0;
&.own {
--active: var(--color-white);
--toggle-fg: var(--color-background-own);
}
}
.toggle {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--toggle-fg);
background-color: var(--active);
}
.content {
flex: 1;
min-width: 0;
}
.title {
overflow: hidden;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.seekline {
cursor: pointer;
touch-action: none;
position: relative;
height: 1rem;
margin: 0.25rem 0;
}
.track,
.fill {
position: absolute;
top: 7px;
height: 2px;
border-radius: 2px;
}
.track {
width: 100%;
background-color: var(--color-interactive-inactive);
}
.fill {
background-color: var(--active);
}
.meta {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,203 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
interface Props {
transcript?: string | null;
url: string;
}
let { url, transcript = null }: Props = $props();
let showTranscript = $state(false);
const RADIUS = 94;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
let element = $state<HTMLVideoElement>();
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0);
const remaining = $derived(Math.max(0, duration - currentTime));
function toggle() {
if (!element) {
return;
}
if (paused) {
element.play().catch(() => undefined);
} else {
element.pause();
}
}
</script>
<div class="RoundVideoWrap">
<button
class="RoundVideo"
onclick={toggle}
type="button"
aria-label="Play video message"
>
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
<video
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
playsinline
preload="metadata"
src={url}
></video>
<svg class="ring" viewBox="0 0 200 200" aria-hidden="true">
<circle
class="ring-progress"
cx="100"
cy="100"
r={RADIUS}
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
/>
</svg>
{#if paused}
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
{/if}
<span class="badge">
<Icon name="microphone" size="0.875rem" />
{formatDuration(paused && currentTime === 0 ? duration : remaining)}
</span>
</button>
{#if transcript}
<button
class="transcribe"
class:active={showTranscript}
onclick={() => (showTranscript = !showTranscript)}
type="button"
>
<Icon name="transcribe" size="1rem" />
Transcription
</button>
{#if showTranscript}
<div class="transcript">{transcript}</div>
{/if}
{/if}
</div>
<style lang="scss">
.RoundVideoWrap {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
.transcribe {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
border: 0;
border-radius: 0.75rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
background-color: var(--color-primary-tint);
&.active {
color: var(--color-primary);
}
}
.transcript {
max-width: 16rem;
font-size: 0.9375rem;
line-height: 1.3;
color: var(--color-text);
white-space: pre-wrap;
}
.RoundVideo {
cursor: pointer;
position: relative;
display: block;
width: 13rem;
height: 13rem;
padding: 0;
border: 0;
background: transparent;
}
video {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
background-color: var(--color-default-shadow);
}
.ring {
pointer-events: none;
position: absolute;
inset: 0;
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.ring-progress {
fill: transparent;
stroke: var(--color-white);
stroke-width: 4;
stroke-linecap: round;
stroke-opacity: 0.9;
transition: stroke-dashoffset 0.2s linear;
}
.play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
.badge {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
border-radius: 0.75rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-white);
background-color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,227 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
import { computeWaveform, flatWaveform } from "$lib/media/waveform";
interface Props {
own: boolean;
transcript?: string | null;
url: string;
}
let { url, own, transcript = null }: Props = $props();
let showTranscript = $state(false);
let element = $state<HTMLAudioElement>();
let peaks = $state<number[]>(flatWaveform());
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0);
const elapsed = $derived(
paused && currentTime === 0 ? duration : currentTime
);
$effect(() => {
let active = true;
computeWaveform(url)
.then((result) => {
if (active) {
peaks = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
function toggle() {
if (!element) {
return;
}
if (paused) {
element.play().catch(() => undefined);
} else {
element.pause();
}
}
function seek(event: PointerEvent) {
if (!(element && duration > 0)) {
return;
}
const rect = event.currentTarget as HTMLElement;
const ratio =
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
}
</script>
<div class="VoiceWrap" class:own>
<div class="Voice">
<button
class="toggle"
onclick={toggle}
type="button"
aria-label="Play voice"
>
<Icon name={paused ? "play" : "pause"} size="1.5rem" />
</button>
<div class="body">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="waveform" onpointerdown={seek}>
{#each peaks as peak, i (i)}
<span
class="bar"
class:filled={i / peaks.length < progress}
style:height={`${Math.round(peak * 100)}%`}
></span>
{/each}
</div>
<div class="time">{formatDuration(elapsed)}</div>
</div>
{#if transcript}
<button
class="transcribe"
class:active={showTranscript}
onclick={() => (showTranscript = !showTranscript)}
type="button"
aria-label="Show transcription"
>
<Icon name="transcribe" size="1.125rem" />
</button>
{/if}
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
<audio
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onplay={() => element && claimPlayback(element)}
onended={() => element && releasePlayback(element)}
preload="metadata"
src={url}
></audio>
</div>
{#if transcript && showTranscript}
<div class="transcript">{transcript}</div>
{/if}
</div>
<style lang="scss">
.VoiceWrap {
--active: var(--color-primary);
--inactive: var(--color-interactive-inactive);
--toggle-fg: var(--color-white);
--voice-meta: var(--color-text-secondary);
--transcribe-bg: var(--color-primary-tint);
&.own {
--active: var(--color-white);
--inactive: rgba(255, 255, 255, 0.45);
--toggle-fg: var(--color-background-own);
--voice-meta: rgba(255, 255, 255, 0.75);
--transcribe-bg: rgba(255, 255, 255, 0.2);
}
}
.Voice {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 13rem;
padding: 0.1875rem 0;
}
.transcribe {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border: 0;
border-radius: 50%;
color: var(--voice-meta);
background-color: var(--transcribe-bg);
&.active {
color: var(--toggle-fg);
background-color: var(--active);
}
}
.transcript {
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px solid var(--color-borders);
font-size: 0.9375rem;
line-height: 1.3;
color: var(--color-text);
white-space: pre-wrap;
}
.toggle {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--toggle-fg);
background-color: var(--active);
}
.body {
flex: 1;
min-width: 0;
}
.waveform {
cursor: pointer;
touch-action: none;
display: flex;
align-items: flex-end;
gap: 1px;
height: 23px;
}
.bar {
flex: 1;
min-height: 2px;
border-radius: 1px;
background-color: var(--inactive);
&.filled {
background-color: var(--active);
}
}
.time {
margin-top: 0.25rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--voice-meta);
}
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import { type AvatarKind, loadAvatar } from "$lib/api/avatars";
import { initials as toInitials } from "$lib/format/peer";
interface Props {
avatar?: { kind: AvatarKind; id: number } | null;
colorKey?: number;
deleted?: boolean;
hasAvatar?: boolean;
name: string;
size?: number;
}
let {
name,
colorKey = 0,
size = 3.375,
deleted = false,
avatar = null,
hasAvatar = false,
}: Props = $props();
const gradients = [
["#ff885e", "#ff516a"],
["#ffcd6a", "#ffa85c"],
["#82b1ff", "#665fff"],
["#a0de7e", "#54cb68"],
["#53edd6", "#28c9b7"],
["#72d5fd", "#2a9ef1"],
["#e0a2f3", "#d669ed"],
];
const pair = $derived(gradients[Math.abs(colorKey) % gradients.length]);
const initials = $derived(toInitials(name));
let url = $state<string | null>(null);
$effect(() => {
url = null;
if (deleted || !(hasAvatar && avatar)) {
return;
}
let active = true;
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
if (active) {
url = resolved;
}
});
return () => {
active = false;
};
});
</script>
<div
class="Avatar"
class:deleted
style="
--avatar-size: {size}rem;
--avatar-from: {pair[0]};
--avatar-to: {pair[1]};
"
>
{#if url}
<img class="photo" src={url} alt={name}>
{:else}
<span class="initials">{initials}</span>
{/if}
</div>
<style lang="scss">
.Avatar {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
font-size: calc(var(--avatar-size) * 0.4);
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--color-white);
text-transform: uppercase;
user-select: none;
background-image: linear-gradient(var(--avatar-from), var(--avatar-to));
&.deleted {
background-image: none;
background-color: var(--color-deleted-account);
}
}
.photo {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -0,0 +1,211 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
import { ripple } from "$lib/actions/ripple";
type Variant =
| "primary"
| "secondary"
| "gray"
| "danger"
| "green"
| "translucent"
| "text";
interface Props extends HTMLButtonAttributes {
children: Snippet;
fluid?: boolean;
loading?: boolean;
pill?: boolean;
round?: boolean;
smaller?: boolean;
tiny?: boolean;
variant?: Variant;
}
let {
variant = "primary",
round = false,
tiny = false,
smaller = false,
pill = false,
fluid = false,
loading = false,
type = "button",
disabled = false,
children,
...rest
}: Props = $props();
</script>
<button
class="Button {variant}"
class:round
class:tiny
class:smaller
class:pill
class:fluid
class:loading
class:disabled
{type}
{disabled}
use:ripple
{...rest}
>
{@render children()}
</button>
<style lang="scss">
.Button {
--button-text-color: white;
--button-background-color: transparent;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 3rem;
padding: 0.625rem;
border: 0;
border-radius: var(--border-radius-button);
font-weight: var(--font-weight-medium);
font-size: 1rem;
line-height: 1.2;
color: var(--button-text-color);
text-decoration: none;
text-transform: uppercase;
background-color: var(--button-background-color);
outline: none;
transition:
background-color 0.2s,
color 0.2s,
opacity 0.2s;
&.disabled {
cursor: default;
opacity: 0.5;
pointer-events: none;
}
&:not(.disabled):active,
&:not(.disabled):focus {
color: var(--button-active-text-color, var(--button-text-color));
background-color: var(
--button-active-background-color,
var(--button-background-color)
);
}
@media (hover: hover) {
&:not(.disabled):hover {
color: var(--button-active-text-color, var(--button-text-color));
background-color: var(
--button-active-background-color,
var(--button-background-color)
);
}
}
&.primary {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-white);
--button-background-color: var(--color-primary);
--button-active-background-color: var(--color-primary-shade);
}
&.secondary {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-text-secondary);
--button-background-color: var(--color-background);
--button-active-text-color: white;
--button-active-background-color: var(--color-primary);
}
&.gray {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-text-secondary);
--button-background-color: var(--color-background);
--button-active-text-color: var(--color-primary);
}
&.danger {
--ripple-color: rgba(var(--color-error-rgb), 0.16);
--button-text-color: var(--color-error);
--button-background-color: var(--color-background);
--button-active-text-color: var(--color-white);
--button-active-background-color: var(--color-error);
}
&.green {
--ripple-color: rgba(0, 0, 0, 0.08);
--button-text-color: var(--color-white);
--button-background-color: var(--color-green);
--button-active-background-color: var(--color-green-darker);
}
&.translucent {
--ripple-color: var(--color-interactive-element-hover);
--button-text-color: var(--color-text-secondary);
--button-background-color: transparent;
--button-active-background-color: var(--color-interactive-element-hover);
}
&.text {
--button-background-color: transparent;
--button-text-color: var(--color-primary);
--button-active-background-color: rgba(var(--color-primary-shade-rgb), 0.08);
text-transform: none;
}
&.fluid {
padding-right: 1.75rem;
padding-left: 1.75rem;
}
&.pill {
padding-right: 1.75rem;
padding-left: 1.75rem;
border-radius: 1.75rem;
text-transform: none;
}
&.smaller {
height: 2.5rem;
padding: 0.3125rem;
&.round {
width: 2.5rem;
}
}
&.tiny {
height: 2.25rem;
padding: 0.4375rem;
border-radius: var(--border-radius-button-tiny);
font-size: 0.875rem;
&.round {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
}
}
&.round {
width: 3rem;
border-radius: 50%;
:global(.icon) {
font-size: 1.5rem;
}
}
}
</style>
@@ -0,0 +1,41 @@
<script lang="ts">
interface Props {
description?: string;
title: string;
}
let { title, description }: Props = $props();
</script>
<div class="root">
<div class="title">{title}</div>
{#if description}
<div class="description">{description}</div>
{/if}
</div>
<style lang="scss">
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 80%;
}
.title {
max-width: 100%;
margin-bottom: 0.125rem;
font-size: 1.25rem;
text-align: center;
overflow-wrap: anywhere;
}
.description {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,15 @@
<script lang="ts">
interface Props {
class?: string;
name: string;
size?: string;
}
let { name, size, class: extra = "" }: Props = $props();
</script>
<i
class="icon icon-{name} {extra}"
style={size ? `font-size: ${size}` : undefined}
aria-hidden="true"
></i>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { ripple } from "$lib/actions/ripple";
interface Props {
children: Snippet;
onclick?: () => void;
selected?: boolean;
}
let { selected = false, onclick, children }: Props = $props();
</script>
<button
type="button"
class="ListItem-button"
class:selected
use:ripple
{onclick}
>
{@render children()}
</button>
<style lang="scss">
.ListItem-button {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
width: 100%;
min-height: 3rem;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: 0.625rem;
font-size: 1rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&.selected {
color: var(--color-white);
background-color: var(--color-chat-active);
}
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
circle?: boolean;
height?: string;
radius?: string;
width?: string;
}
let {
width = "100%",
height = "1rem",
radius = "0.375rem",
circle = false,
}: Props = $props();
</script>
<div
class="skeleton"
style:width
style:height
style:border-radius={circle ? "50%" : radius}
></div>
@@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
color?: "blue" | "white" | "gray";
size?: string;
}
let { size = "2rem", color = "blue" }: Props = $props();
</script>
<div class="Spinner {color}" style="--spinner-size: {size}"></div>
<style lang="scss">
.Spinner {
width: var(--spinner-size);
height: var(--spinner-size);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
animation: spinner-rotate 800ms linear infinite;
&.blue {
background-image: var(--spinner-blue-data);
}
&.white {
background-image: var(--spinner-white-data);
}
&.gray {
background-image: var(--spinner-gray-data);
}
}
@keyframes spinner-rotate {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,57 @@
<script lang="ts">
import { toasts } from "$lib/stores/toasts.svelte";
</script>
<div class="Notification-container">
{#each toasts.items as toast (toast.id)}
<button
type="button"
class="Notification {toast.type}"
onclick={() => toasts.dismiss(toast.id)}
>
{toast.message}
</button>
{/each}
</div>
<style lang="scss">
.Notification-container {
pointer-events: none;
position: fixed;
z-index: var(--z-notification);
right: 0;
bottom: 1.25rem;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.Notification {
pointer-events: auto;
cursor: pointer;
max-width: 22rem;
padding: 0.75rem 1.25rem;
border: 0;
border-radius: var(--border-radius-toast);
font-size: 0.9375rem;
text-align: start;
color: var(--color-white);
background-color: var(--color-toast-background);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
&.error {
background-color: var(--color-error);
}
&.success {
background-color: var(--color-green);
}
}
</style>
+39
View File
@@ -0,0 +1,39 @@
const time = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
});
const day = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
});
const full = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export function formatTime(iso: string): string {
return time.format(new Date(iso));
}
export function formatDay(iso: string): string {
return day.format(new Date(iso));
}
export function formatFull(iso: string): string {
return full.format(new Date(iso));
}
export function formatListDate(iso: string | null): string {
if (!iso) {
return "";
}
const date = new Date(iso);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return time.format(date);
}
return day.format(date);
}
+9
View File
@@ -0,0 +1,9 @@
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return "0:00";
}
const total = Math.floor(seconds);
const minutes = Math.floor(total / 60);
const rest = total % 60;
return `${minutes}:${rest.toString().padStart(2, "0")}`;
}
+105
View File
@@ -0,0 +1,105 @@
import type { EntityView } from "$lib/api/types";
export interface TextNode {
kind: "text";
text: string;
}
export interface EntityNode {
children: EntityTreeNode[];
entity: EntityView;
kind: "entity";
}
export type EntityTreeNode = TextNode | EntityNode;
function buildNodes(
text: string,
entities: EntityView[],
start: number,
end: number
): EntityTreeNode[] {
const nodes: EntityTreeNode[] = [];
let cursor = start;
let i = 0;
while (i < entities.length) {
const entity = entities[i];
const entityEnd = entity.offset + entity.length;
if (entity.offset > cursor) {
nodes.push({ kind: "text", text: text.slice(cursor, entity.offset) });
}
const nested: EntityView[] = [];
let next = i + 1;
while (next < entities.length && entities[next].offset < entityEnd) {
nested.push(entities[next]);
next += 1;
}
nodes.push({
kind: "entity",
entity,
children: buildNodes(text, nested, entity.offset, entityEnd),
});
cursor = entityEnd;
i = next;
}
if (cursor < end) {
nodes.push({ kind: "text", text: text.slice(cursor, end) });
}
return nodes;
}
export function buildEntityTree(
text: string,
entities: EntityView[]
): EntityTreeNode[] {
if (entities.length === 0) {
return [{ kind: "text", text }];
}
const sorted = [...entities].sort(
(a, b) => a.offset - b.offset || b.length - a.length
);
return buildNodes(text, sorted, 0, text.length);
}
const PROTOCOL_RE = /^[a-z]+:/i;
function ensureProtocol(url: string): string {
return PROTOCOL_RE.test(url) ? url : `https://${url}`;
}
export function nodeText(node: EntityTreeNode): string {
if (node.kind === "text") {
return node.text;
}
return node.children.map(nodeText).join("");
}
export function linkHref(node: EntityNode): string {
const { entity } = node;
if (entity.type === "text_link" && entity.url) {
return ensureProtocol(entity.url);
}
if (entity.type === "email") {
return `mailto:${nodeText(node)}`;
}
if (entity.type === "phone_number") {
return `tel:${nodeText(node)}`;
}
return ensureProtocol(nodeText(node));
}
const EMOJI_RE = /\p{Extended_Pictographic}/u;
const EMOJI_CLUSTER_RE =
/\p{Extended_Pictographic}(?:|\p{Extended_Pictographic}?)*/gu;
export function jumboEmojiCount(text: string): number {
const trimmed = text.trim();
if (!(trimmed && EMOJI_RE.test(trimmed))) {
return 0;
}
const clusters = trimmed.match(EMOJI_CLUSTER_RE);
if (!clusters || trimmed.replace(EMOJI_CLUSTER_RE, "").trim().length > 0) {
return 0;
}
return clusters.length <= 3 ? clusters.length : 0;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Chat, Folder } from "$lib/api/types";
function categoryMatch(folder: Folder, chat: Chat): boolean {
if (chat.is_broadcast) {
return folder.broadcasts;
}
if (chat.kind === "group") {
return folder.groups;
}
if (chat.is_bot) {
return folder.bots;
}
if (chat.is_contact) {
return folder.contacts;
}
return folder.non_contacts;
}
export function folderContains(folder: Folder, chat: Chat): boolean {
if (folder.exclude_ids.includes(chat.chat_id)) {
return false;
}
if (
folder.include_ids.includes(chat.chat_id) ||
folder.pinned_ids.includes(chat.chat_id)
) {
return true;
}
if (folder.is_chatlist) {
return false;
}
return categoryMatch(folder, chat);
}
+25
View File
@@ -0,0 +1,25 @@
const MEDIA_KIND_LABELS: Record<string, string> = {
photo: "Photo",
video: "Video",
animation: "GIF",
gif: "GIF",
voice: "Voice message",
audio: "Audio",
video_note: "Video message",
sticker: "Sticker",
document: "File",
contact: "Contact",
location: "Location",
venue: "Location",
poll: "Poll",
dice: "Dice",
game: "Game",
story: "Story",
};
export function mediaKindLabel(kind: string | null): string | null {
if (!kind) {
return null;
}
return MEDIA_KIND_LABELS[kind] ?? "Media";
}
+48
View File
@@ -0,0 +1,48 @@
import type { Account, PeerView } from "$lib/api/types";
const PEER_COLOR_COUNT = 7;
export function peerColorIndex(id: number): number {
return Math.abs(id) % PEER_COLOR_COUNT;
}
export function peerName(peer: PeerView | null): string {
if (!peer) {
return "";
}
if (peer.is_deleted_account) {
return "Deleted Account";
}
const parts = [peer.first_name, peer.last_name].filter(Boolean);
if (parts.length > 0) {
return parts.join(" ");
}
if (peer.username) {
return `@${peer.username}`;
}
return String(peer.peer_id);
}
export function accountName(account: Account): string {
return (
account.label ??
account.phone ??
String(account.tg_user_id ?? account.account_id)
);
}
const WHITESPACE = /\s+/;
export function initials(name: string): string {
const words = name.trim().split(WHITESPACE).filter(Boolean);
if (words.length === 0) {
return "?";
}
const first = words[0][0];
if (words.length === 1) {
return first.toUpperCase();
}
const lastWord = words.at(-1);
const last = lastWord ? lastWord[0] : "";
return (first + last).toUpperCase();
}
+14
View File
@@ -0,0 +1,14 @@
let current: HTMLMediaElement | null = null;
export function claimPlayback(element: HTMLMediaElement) {
if (current && current !== element) {
current.pause();
}
current = element;
}
export function releasePlayback(element: HTMLMediaElement) {
if (current === element) {
current = null;
}
}
+36
View File
@@ -0,0 +1,36 @@
const BARS = 48;
const MIN_BAR = 0.08;
export async function computeWaveform(
url: string,
bars = BARS
): Promise<number[]> {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const ctx = new AudioContext();
try {
const audio = await ctx.decodeAudioData(buffer);
const data = audio.getChannelData(0);
const block = Math.max(1, Math.floor(data.length / bars));
const peaks: number[] = [];
for (let i = 0; i < bars; i++) {
const start = i * block;
let max = 0;
for (let j = 0; j < block; j++) {
const value = Math.abs(data[start + j] ?? 0);
if (value > max) {
max = value;
}
}
peaks.push(max);
}
const norm = Math.max(...peaks, 0.0001);
return peaks.map((peak) => Math.max(MIN_BAR, peak / norm));
} finally {
await ctx.close();
}
}
export function flatWaveform(bars = BARS): number[] {
return Array.from({ length: bars }, () => 0.3);
}
@@ -0,0 +1,66 @@
import { browser } from "$app/environment";
import { listAccounts } from "$lib/api/endpoints";
import type { Account } from "$lib/api/types";
const STORAGE_KEY = "bg.account";
function readSelected(): number | null {
if (!browser) {
return null;
}
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? null : Number(stored);
}
function createAccounts() {
let list = $state<Account[]>([]);
let selectedId = $state<number | null>(readSelected());
let loaded = $state(false);
const selected = $derived(
list.find((account) => account.account_id === selectedId) ?? null
);
function persist(id: number | null) {
if (!browser) {
return;
}
if (id === null) {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, String(id));
}
}
return {
get list() {
return list;
},
get selectedId() {
return selectedId;
},
get selected() {
return selected;
},
get loaded() {
return loaded;
},
async load() {
list = await listAccounts();
loaded = true;
const exists = list.some((account) => account.account_id === selectedId);
if (!exists) {
const fallback =
list.find((account) => account.is_active) ?? list.at(0) ?? null;
selectedId = fallback ? fallback.account_id : null;
persist(selectedId);
}
},
select(id: number) {
selectedId = id;
persist(id);
},
};
}
export const accounts = createAccounts();
+37
View File
@@ -0,0 +1,37 @@
import { browser } from "$app/environment";
const STORAGE_KEY = "bg.token";
function readToken(): string | null {
if (!browser) {
return null;
}
return localStorage.getItem(STORAGE_KEY);
}
function createAuth() {
let token = $state<string | null>(readToken());
return {
get token() {
return token;
},
get isAuthenticated() {
return token !== null && token.length > 0;
},
login(value: string) {
token = value;
if (browser) {
localStorage.setItem(STORAGE_KEY, value);
}
},
logout() {
token = null;
if (browser) {
localStorage.removeItem(STORAGE_KEY);
}
},
};
}
export const auth = createAuth();
+93
View File
@@ -0,0 +1,93 @@
import { enrichChat, getJob, listChats } from "$lib/api/endpoints";
import type { Chat } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
const POLL_INTERVAL = 1500;
const POLL_MAX = 12;
function createChats() {
let list = $state<Chat[]>([]);
let loaded = $state(false);
let loading = $state(false);
let revision = $state(0);
let account: number | null = null;
const enriched = new Set<number>();
function syncAccount() {
if (accounts.selectedId !== account) {
account = accounts.selectedId;
list = [];
loaded = false;
enriched.clear();
}
}
async function load(force: boolean) {
syncAccount();
if (account === null || (loaded && !force)) {
return;
}
loading = true;
try {
list = await listChats({ limit: 200 });
loaded = true;
} finally {
loading = false;
}
}
async function waitForJob(jobId: number) {
for (let attempt = 0; attempt < POLL_MAX; attempt++) {
await new Promise((resolve) => {
setTimeout(resolve, POLL_INTERVAL);
});
const job = await getJob(jobId);
if (job.status === "done" || job.status === "failed") {
return;
}
}
}
return {
get list(): Chat[] {
return list;
},
get loaded(): boolean {
return loaded;
},
get loading(): boolean {
return loading;
},
get revision(): number {
return revision;
},
byId(id: number): Chat | undefined {
return list.find((chat) => chat.chat_id === id);
},
load() {
return load(false);
},
refresh() {
return load(true);
},
async enrich(chatId: number) {
syncAccount();
if (account === null || enriched.has(chatId)) {
return;
}
enriched.add(chatId);
try {
const { job_id } = await enrichChat(chatId);
await waitForJob(job_id);
peers.reset();
await load(true);
revision++;
} catch {
enriched.delete(chatId);
}
},
};
}
export const chats = createChats();
+74
View File
@@ -0,0 +1,74 @@
import { listFolders } from "$lib/api/endpoints";
import type { Folder } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
function createFolders() {
let list = $state<Folder[]>([]);
let loaded = $state(false);
let loading = $state(false);
let selectedId = $state<number | null>(null);
let direction = $state(1);
let account: number | null = null;
function syncAccount() {
if (accounts.selectedId !== account) {
account = accounts.selectedId;
list = [];
loaded = false;
selectedId = null;
}
}
function indexOf(id: number | null): number {
if (id === null) {
return 0;
}
const index = list.findIndex((folder) => folder.folder_id === id);
return index === -1 ? 0 : index + 1;
}
async function load() {
syncAccount();
if (account === null || loaded) {
return;
}
loading = true;
try {
const folders = await listFolders();
list = folders.sort((a, b) => a.order_index - b.order_index);
loaded = true;
} finally {
loading = false;
}
}
return {
get list(): Folder[] {
return list;
},
get loading(): boolean {
return loading;
},
get selectedId(): number | null {
return selectedId;
},
get selected(): Folder | null {
return list.find((folder) => folder.folder_id === selectedId) ?? null;
},
get activeIndex(): number {
return indexOf(selectedId);
},
get direction(): number {
return direction;
},
load() {
return load();
},
select(id: number | null) {
direction = indexOf(id) >= indexOf(selectedId) ? 1 : -1;
selectedId = id;
},
};
}
export const folders = createFolders();
+70
View File
@@ -0,0 +1,70 @@
import { getPeers } from "$lib/api/endpoints";
import type { PeerView } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
const BATCH_DELAY = 40;
function createPeers() {
let map = $state<Record<number, PeerView>>({});
const requested = new Set<number>();
const pending = new Set<number>();
let account: number | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
map = {};
requested.clear();
pending.clear();
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
function flush() {
timer = null;
const ids = [...pending];
pending.clear();
if (ids.length === 0) {
return;
}
getPeers(ids)
.then((peers) => {
const next = { ...map };
for (const peer of peers) {
next[peer.peer_id] = peer;
}
map = next;
})
.catch(() => {
for (const id of ids) {
requested.delete(id);
}
});
}
return {
reset,
get(id: number): PeerView | undefined {
return map[id];
},
ensure(ids: Iterable<number>) {
if (accounts.selectedId !== account) {
account = accounts.selectedId;
reset();
}
for (const id of ids) {
if (map[id] || requested.has(id)) {
continue;
}
requested.add(id);
pending.add(id);
}
if (pending.size > 0 && timer === null) {
timer = setTimeout(flush, BATCH_DELAY);
}
},
};
}
export const peers = createPeers();
+67
View File
@@ -0,0 +1,67 @@
import { browser } from "$app/environment";
type Preference = "light" | "dark" | "system";
type Resolved = "light" | "dark";
const STORAGE_KEY = "bg.theme";
function readPreference(): Preference {
if (!browser) {
return "system";
}
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
return "system";
}
function prefersDark(): boolean {
return browser && window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function createTheme() {
let preference = $state<Preference>(readPreference());
let systemDark = $state(prefersDark());
function systemResolved(): Resolved {
return systemDark ? "dark" : "light";
}
const resolved = $derived<Resolved>(
preference === "system" ? systemResolved() : preference
);
function watchSystem() {
if (!browser) {
return () => undefined;
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = (event: MediaQueryListEvent) => {
systemDark = event.matches;
};
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
return {
get preference() {
return preference;
},
get resolved() {
return resolved;
},
set(value: Preference) {
preference = value;
if (browser) {
localStorage.setItem(STORAGE_KEY, value);
}
},
toggle() {
this.set(this.resolved === "dark" ? "light" : "dark");
},
watchSystem,
};
}
export const theme = createTheme();
+47
View File
@@ -0,0 +1,47 @@
type ToastType = "info" | "error" | "success";
interface Toast {
id: number;
message: string;
type: ToastType;
}
const DEFAULT_TIMEOUT = 4000;
function createToasts() {
let items = $state<Toast[]>([]);
let nextId = 0;
function dismiss(id: number) {
items = items.filter((toast) => toast.id !== id);
}
function show(
message: string,
type: ToastType = "info",
timeout = DEFAULT_TIMEOUT
) {
const id = nextId++;
items = [...items, { id, type, message }];
if (timeout > 0) {
setTimeout(() => dismiss(id), timeout);
}
return id;
}
return {
get items() {
return items;
},
show,
error(message: string) {
return show(message, "error");
},
success(message: string) {
return show(message, "success");
},
dismiss,
};
}
export const toasts = createToasts();
+39
View File
@@ -0,0 +1,39 @@
export type RightPanel =
| "profile"
| "search"
| "versions"
| "reactions"
| "links"
| "annotations"
| "jobs"
| "presence"
| "stories"
| "policy";
function createUi() {
let rightPanel = $state<RightPanel | null>(null);
let leftColumnOpen = $state(true);
return {
get rightPanel() {
return rightPanel;
},
get leftColumnOpen() {
return leftColumnOpen;
},
openPanel(panel: RightPanel) {
rightPanel = panel;
},
closePanel() {
rightPanel = null;
},
toggleLeftColumn() {
leftColumnOpen = !leftColumnOpen;
},
setLeftColumn(open: boolean) {
leftColumnOpen = open;
},
};
}
export const ui = createUi();
+73
View File
@@ -0,0 +1,73 @@
html.theme-dark {
--color-primary: #8774e1;
--color-primary-opacity: #8378db1e;
--color-primary-opacity-hover: #8378db40;
--color-primary-tint: #8774e11a;
--color-primary-shade: #7b71c6;
--color-background: #212121;
--color-background-compact-menu: #212121dd;
--color-web-app-browser: #0303038f;
--color-background-compact-menu-reactions: #212121dd;
--color-background-compact-menu-hover: #00000066;
--color-background-secondary: #0f0f0f;
--color-background-secondary-accent: #191919;
--color-background-sidebar: #0f0f0f;
--color-background-own: #766ac8;
--color-background-own-apple: #766ac8;
--color-background-selected: #2c2c2c;
--color-background-own-selected: #6549d4;
--color-chat-hover: #2c2c2c;
--color-chat-active: #766ac8;
--color-chat-active-greyed: #9288d3;
--color-item-hover: #2c2c2c;
--color-item-active: #292929;
--color-text: #ffffff;
--color-text-secondary: #aaaaaa;
--color-icon-secondary: #aaaaaa;
--color-text-secondary-apple: #aaaaaa;
--color-borders: #303030;
--color-borders-input: #5b5b5a;
--color-dividers: #3b3b3d;
--color-dividers-android: #0f0f0f;
--color-links: #8774e1;
--color-gray: #717579;
--color-list-icon: #a2a2a2;
--color-default-shadow: #1010109c;
--color-light-shadow: #00000040;
--color-active: #8774e1;
--color-active-darker: #7b71c6;
--color-green: #00c73e;
--color-green-darker: #00a734;
--color-success: #00c73e;
--color-text-meta-colored: #8378db;
--color-reply-hover: #272727;
--color-reply-active: #2e2f2f;
--color-reply-own-hover: #8775da;
--color-reply-own-hover-apple: #8775da;
--color-reply-own-active: #917dea;
--color-reply-own-active-apple: #917dea;
--color-accent-own: #ffffff;
--color-message-meta-own: #ffffff88;
--color-own-links: #ffffff;
--color-code: #8774e1;
--color-code-own: #ffffff;
--color-code-bg: #00000080;
--color-code-own-bg: #00000050;
--color-composer-button: #aaaaaacc;
--color-message-reaction: #2b2a35;
--color-message-reaction-hover: #343147;
--color-message-reaction-own: #675caf;
--color-message-reaction-hover-own: #5b529b;
--color-message-reaction-chosen-hover: #7864dd;
--color-message-reaction-chosen-hover-own: #f5f5f5;
--color-message-non-contact: #aaaaaa;
--color-voice-transcribe-button: #2a2a3c;
--color-voice-transcribe-button-own: #8373d3;
--color-chat-username: #e9eef4;
--color-borders-read-story: #737373;
--color-background-menu-separator: #ffffff1a;
--color-hover-overlay: #ffffff06;
--color-toast-background: #000000cc;
--color-deleted-account: #9eaab5;
--color-archive: #9eaab5;
}
+270
View File
@@ -0,0 +1,270 @@
.max-length-indicator {
position: absolute;
right: 0.75rem;
bottom: -0.5625rem;
padding: 0 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
background: var(--color-background);
}
.input-group {
position: relative;
margin-bottom: 1.125rem;
label {
pointer-events: none;
cursor: var(--custom-cursor, text);
position: absolute;
top: 0.6875rem;
left: 1rem;
transform-origin: left center;
display: block;
padding: 0 0.5rem;
border-radius: 1rem;
font-size: 1rem;
font-weight: var(--font-weight-normal);
color: var(--color-placeholders);
white-space: nowrap;
background-color: var(--color-background);
transition: transform 0.15s ease-out, color 0.15s ease-out;
}
&.with-arrow {
&::after {
content: "";
position: absolute;
top: 1rem;
right: 2rem;
transform: rotate(-45deg);
width: 0.75rem;
height: 0.75rem;
border-bottom: 1px var(--color-text-secondary) solid;
border-left: 1px var(--color-text-secondary) solid;
}
}
&.touched label,
&.error label,
&.success label,
.form-control:focus + label,
.form-control.focus + label {
transform: scale(0.75) translate(0, -2rem);
}
input::placeholder,
.form-control::placeholder {
color: var(--color-placeholders);
}
&.touched label {
color: var(--color-text-secondary);
}
&.error label {
color: var(--color-error) !important;
}
&.success label {
color: var(--color-text-green) !important;
}
&.disabled {
pointer-events: none;
opacity: 0.5;
}
&[dir="rtl"] {
input {
text-align: right;
}
label {
right: 0.75rem;
left: auto;
}
&.with-arrow {
&::after {
right: auto;
left: 2rem;
border-right: 1px var(--color-text-secondary) solid;
border-left: none;
}
}
&.touched label,
&.error label,
&.success label,
.form-control:focus + label,
.form-control.focus + label {
transform: scale(0.75) translate(1.5rem, -2.25rem);
}
}
}
.form-control {
--border-width: 1px;
display: block;
width: 100%;
height: 3rem;
padding: calc(0.75rem - var(--border-width)) calc(1.1875rem - var(--border-width)) 0.6875rem;
border: var(--border-width) solid var(--color-borders-input);
border-radius: var(--border-radius-default);
font-size: 1rem;
line-height: 1.25rem;
color: var(--color-text);
overflow-wrap: anywhere;
-webkit-appearance: none;
background-color: var(--color-background);
outline: none;
transition: border-color 0.15s ease;
// Hide hint for Safari password strength meter
&::-webkit-strong-password-auto-fill-button {
position: absolute;
overflow: hidden !important;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
opacity: 0;
clip-path: inset(50%);
}
&::-ms-clear,
&::-ms-reveal {
display: none;
}
&[dir] {
text-align: initial;
}
&:hover {
border-color: var(--color-primary);
& + label {
color: var(--color-primary);
}
}
&:focus,
&.focus {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
caret-color: var(--color-primary);
& + label {
color: var(--color-primary);
}
}
&:disabled {
background: none !important;
}
.error & {
border-color: var(--color-error);
box-shadow: inset 0 0 0 1px var(--color-error);
caret-color: var(--color-error);
}
.success & {
border-color: var(--color-text-green);
box-shadow: inset 0 0 0 1px var(--color-text-green);
caret-color: var(--color-text-green);
}
// Disable yellow highlight on autofill
&:autofill,
&:-webkit-autofill-strong-password,
&:-webkit-autofill-strong-password-viewable,
&:-webkit-autofill-and-obscured {
box-shadow: inset 0 0 0 10rem var(--color-background);
-webkit-text-fill-color: var(--color-text);
}
}
select.form-control {
option {
line-height: 2rem;
}
}
textarea.form-control {
resize: none;
overflow: hidden;
padding-top: calc(0.8125rem - var(--border-width));
padding-bottom: calc(1rem - var(--border-width));
line-height: 1.3125rem;
}
.input-group.password-input {
position: relative;
.form-control {
padding-right: 3.375rem;
}
.toggle-password {
cursor: var(--custom-cursor, pointer);
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
font-size: 1.5rem;
color: var(--color-text-secondary);
opacity: 0.7;
outline: none !important;
&:hover,
&:focus {
opacity: 1;
}
}
&[dir="rtl"] {
.form-control {
padding-right: calc(0.9rem - var(--border-width));
padding-left: 3.375rem;
}
.toggle-password {
right: auto;
left: 0;
}
}
}
+202
View File
@@ -0,0 +1,202 @@
@use "variables";
@use "spacing";
@use "forms";
@use "dark-theme";
html,
body {
height: 100%;
margin: 0;
}
body {
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 1rem;
color: var(--color-text);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
.icon::before {
font-family: "icons" !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 0.375rem;
height: 0.375rem;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: 0.375rem;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
button:focus,
a:focus,
[tabindex]:focus {
outline: none;
}
.custom-scroll {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
transition: scrollbar-color 0.3s ease;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
width: 0.375rem;
height: 0.375rem;
}
&::-webkit-scrollbar-thumb {
border-radius: 0.375rem;
background-color: transparent;
box-shadow: 0 0 1px rgba(255, 255, 255, 0.01);
}
&:hover,
&:focus,
&:focus-within {
scrollbar-color: var(--color-scrollbar) transparent;
&::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
}
}
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-skeleton-background) 25%,
var(--color-skeleton-foreground) 37%,
var(--color-skeleton-background) 63%
);
background-size: 400% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
html.theme-transition,
html.theme-transition * {
transition:
background-color 0.25s ease,
border-color 0.25s ease,
color 0.25s ease !important;
}
@keyframes ripple-animation {
from {
transform: scale(0);
opacity: 1;
}
50% {
opacity: 1;
}
to {
transform: scale(2);
opacity: 0;
}
}
.ripple-container {
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden;
}
.ripple-wave {
pointer-events: none;
position: absolute;
transform: scale(0);
display: block;
border-radius: 50%;
background-color: var(--ripple-color, rgba(0, 0, 0, 0.08));
animation: ripple-animation 700ms;
}
.bg-menu-content {
z-index: var(--z-portal-menu);
min-width: 12rem;
padding: 0.375rem;
border-radius: 0.75rem;
color: var(--color-text);
background-color: var(--color-background-compact-menu);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 1rem var(--color-default-shadow);
outline: none;
}
html.theme-dark .bg-menu-content {
border: 1px solid var(--color-borders);
}
.bg-menu-item {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
color: var(--color-text);
outline: none;
transition: background-color 0.15s ease;
&[data-highlighted],
&:hover {
background-color: var(--color-background-compact-menu-hover);
}
&[data-selected] {
color: var(--color-primary);
}
}
.bg-menu-separator {
height: 1px;
margin: 0.375rem 0;
background-color: var(--color-background-menu-separator);
}
$peer-colors: #e17076, #eda86c, #a695e7, #7bc862, #6ec9cb, #65aadd, #ee7aae;
@for $i from 0 through 6 {
.peer-color-#{$i} {
color: nth($peer-colors, $i + 1);
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+271
View File
@@ -0,0 +1,271 @@
// @optimization
@mixin while-transition() {
.Transition_slide:not(.Transition_slide-active) & {
@content;
}
}
@mixin adapt-padding-to-scrollbar($padding, $forceSpace: 0px) {
padding-inline-end: calc(max($padding - var(--scrollbar-width), $forceSpace));
}
@mixin adapt-margin-to-scrollbar($margin, $forceSpace: 0px) {
margin-inline-end: calc(max($margin - var(--scrollbar-width), $forceSpace));
}
@mixin filter-outline($width: 0.125rem, $color) {
filter:
drop-shadow($width $width 0 $color)
drop-shadow((-$width) $width 0 $color)
drop-shadow($width (-$width) 0 $color)
drop-shadow((-$width) (-$width) 0 $color);
}
@mixin gradient-border-top($width, $cutout: 0px) {
mask-image: linear-gradient(transparent $cutout, black $width);
}
@mixin gradient-border-bottom($height, $cutout: 0px) {
mask-image: linear-gradient(to top, transparent $cutout, black $height);
}
@mixin gradient-border-horizontal($borderStart, $borderEnd) {
mask-image: linear-gradient(to right, transparent, black $borderStart, black calc(100% - $borderEnd), transparent);
}
@mixin gradient-border-left($indent, $cutout: 0px) {
mask-image: linear-gradient(to right, transparent $cutout, black $indent);
}
@mixin gradient-border-right($indent, $cutout: 0px) {
mask-image: linear-gradient(to left, transparent $cutout, black $indent);
}
@mixin gradient-border-top-bottom($top, $bottom) {
mask-image: linear-gradient(transparent 0%, black $top, black calc(100% - $bottom), transparent 100%);
}
@mixin peer-gradient($property, $colorCount) {
--_accent-color-rgb: var(--color-accent-own-rgb);
html.theme-dark {
--_accent-color-rgb: var(--color-text-rgb);
}
@if $colorCount == 2 {
#{$property}:
repeating-linear-gradient(
-45deg,
rgb(var(--_accent-color-rgb), 100%),
rgb(var(--_accent-color-rgb), 100%) 5px,
rgb(var(--_accent-color-rgb), 35%) 5px,
rgb(var(--_accent-color-rgb), 35%) 10px
);
}
@else {
#{$property}:
repeating-linear-gradient(
-45deg,
rgb(var(--_accent-color-rgb), 100%),
rgb(var(--_accent-color-rgb), 100%) 5px,
rgb(var(--_accent-color-rgb), 60%) 5px,
rgb(var(--_accent-color-rgb), 60%) 10px,
rgb(var(--_accent-color-rgb), 20%) 10px,
rgb(var(--_accent-color-rgb), 20%) 15px
);
}
}
@mixin reset-range() {
input[type="range"] {
display: block;
width: 100%;
margin-bottom: 0.5rem;
-webkit-appearance: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
}
&::-moz-range-thumb {
-moz-appearance: none;
}
&::-webkit-slider-runnable-track {
cursor: var(--custom-cursor, pointer);
}
&::-moz-range-track, &::-moz-range-progress {
cursor: var(--custom-cursor, pointer);
}
&:focus {
outline: none;
}
}
}
@mixin middle-header-pane {
position: absolute;
top: 0;
transform: translateY(-100%);
width: 100%;
height: 2.875rem;
padding-top: 0.375rem;
padding-right: max(0.5rem, env(safe-area-inset-right));
padding-bottom: 0.375rem;
padding-left: max(0.75rem, env(safe-area-inset-left));
background-color: var(--color-background);
transition: transform var(--slide-transition);
&::before {
pointer-events: none;
content: "";
position: absolute;
top: -0.1875rem;
right: 0;
left: 0;
display: block;
height: 0.125rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
}
// Some panels might unmount without animation, so we provide same background above panel to make it less noticeable
&::after {
content: "";
position: absolute;
z-index: -1;
top: -100%;
right: 0;
left: 0;
height: inherit;
background-color: inherit;
}
}
@mixin chat-list-pane {
position: absolute;
top: 0;
transform: translateY(calc(-100% - 0.5rem)); // Include top margin to hide fully
width: 100%;
padding: 0.5625rem;
border-radius: var(--border-radius-island);
background-color: var(--color-background);
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
transition: transform var(--chat-transform-transition);
// Some panels might unmount without animation, so we provide same background above panel to make it less noticeable
&::after {
content: "";
position: absolute;
z-index: -1;
top: -100%;
right: 0;
left: 0;
height: inherit;
background-color: inherit;
}
:global(html.theme-dark) & {
border: 1px solid var(--color-borders);
box-shadow: none;
}
}
@mixin side-panel-section {
border-bottom: 0.625rem solid var(--color-background-secondary);
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
&:last-child {
border-bottom: none;
box-shadow: none;
}
}
@mixin on-active-vt($type) {
:global {
.active-vt-#{$type} {
@content;
}
}
}
@mixin with-vt-type($type) {
:global(.active-vt-#{$type}) & {
view-transition-name: var(--_vtn);
}
}
@mixin chat-pattern-styles($path) {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.4;
background-image: url($path);
background-repeat: repeat;
background-position: center;
background-size: 430px auto;
mix-blend-mode: soft-light;
:global(html.theme-dark) & {
opacity: 0.25;
background: linear-gradient(145deg, #4f5bd5 0%, #962fbf 35%, #dd6cb9 65%, #fec496 100%) !important;
background-image: none;
mix-blend-mode: unset;
mask-image: url($path);
mask-repeat: repeat;
mask-position: center;
mask-size: 430px auto;
}
}
@mixin chat-pattern-background($path) {
&::before {
@include chat-pattern-styles($path);
}
}
@mixin action-message-bg($isModule: false, $noBackground: false) {
@if not $noBackground {
background-color: var(--action-message-bg);
}
@if $isModule {
:global(body.with-message-blur) & {
backdrop-filter: blur(4px);
}
}
@else {
body.with-message-blur & {
backdrop-filter: blur(4px);
}
}
}
+334
View File
@@ -0,0 +1,334 @@
/* stylelint-disable selector-max-type */
/* stylelint-disable plugin/selector-tag-no-without-class */
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
article,
aside,
dialog,
figcaption,
figure,
footer,
header,
hgroup,
main,
nav,
section {
display: block;
}
body,
blockquote {
margin: 0;
}
[tabindex="-1"]:focus {
outline: none !important;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: var(--font-weight-medium);
}
abbr[title],
abbr[data-original-title] {
text-decoration: underline;
text-decoration: underline dotted;
cursor: help;
border-bottom: 0;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
p,
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
figure {
margin: 0 0 1rem;
}
dfn {
font-style: italic;
}
dt,
b,
strong {
font-weight: var(--font-weight-medium);
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: var(--color-links);
text-decoration: none;
background-color: transparent;
-webkit-text-decoration-skip: objects;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
a:not([href]):not([tabindex]),
a:not([href]):not([tabindex]):hover,
a:not([href]):not([tabindex]):focus {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):focus {
outline: 0;
}
pre,
code,
kbd,
samp {
/* stylelint-disable-next-line @stylistic/max-line-length */
font:
0.9375rem / 1.25 "Courier",
"Courier New",
"Nimbus Mono L",
"Courier 10 Pitch",
"FreeMono",
sans-serif-monospace,
monospace;
font-size-adjust: 0.5;
}
pre {
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
}
img {
vertical-align: middle;
border-style: none;
}
img,
video {
dynamic-range-limit: standard;
}
svg:not(:root) {
overflow: hidden;
}
a,
area,
button,
[role="button"],
input:not([type="range"]),
label,
select,
summary,
textarea {
touch-action: manipulation;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #868e96;
text-align: left;
caption-side: bottom;
}
th {
text-align: inherit;
}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
button {
-webkit-appearance: button;
border-radius: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type="radio"],
input[type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: 1.5rem;
line-height: inherit;
color: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: none;
outline-offset: -2px;
}
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
}
+52
View File
@@ -0,0 +1,52 @@
@use "sass:map";
$spacer: 1rem !default;
$spacers: () !default;
$spacers: map.merge(
(
0: 0,
1: (
$spacer * 0.25,
),
2: (
$spacer * 0.5,
),
3: $spacer,
4: (
$spacer * 1.5,
),
5: (
$spacer * 2,
),
6: (
$spacer * 3,
),
),
$spacers
);
// Margin and Padding
@each $prop, $abbrev in (margin: m, padding: p) {
@each $size, $length in $spacers {
.#{$abbrev}-#{$size} {
#{$prop}: $length !important;
}
.#{$abbrev}t-#{$size},
.#{$abbrev}y-#{$size} {
#{$prop}-top: $length !important;
}
.#{$abbrev}r-#{$size},
.#{$abbrev}x-#{$size} {
#{$prop}-right: $length !important;
}
.#{$abbrev}b-#{$size},
.#{$abbrev}y-#{$size} {
#{$prop}-bottom: $length !important;
}
.#{$abbrev}l-#{$size},
.#{$abbrev}x-#{$size} {
#{$prop}-left: $length !important;
}
}
}
+1
View File
@@ -0,0 +1 @@
@forward "mixins";
+359
View File
@@ -0,0 +1,359 @@
@use "sass:color";
@function toRGB($color) {
@return color.channel($color, "red") + ", " + color.channel($color, "green") + ", " + color.channel($color, "blue");
}
@function blend-normal($foreground, $background) {
$opacity: color.opacity($foreground);
$background-opacity: color.opacity($background);
// calculate opacity
/* stylelint-disable @stylistic/max-line-length */
$bm-red: color.channel($foreground, "red") * $opacity + color.channel($background, "red") * $background-opacity * (1 - $opacity);
$bm-green: color.channel($foreground, "green") * $opacity + color.channel($background, "green") * $background-opacity * (1 - $opacity);
$bm-blue: color.channel($foreground, "blue") * $opacity + color.channel($background, "blue") * $background-opacity * (1 - $opacity);
/* stylelint-enable @stylistic/max-line-length */
@return rgb($bm-red, $bm-green, $bm-blue);
}
@layer variables {
$color-primary: #3390ec;
$color-links: #3390ec;
$color-placeholders: #a2acb4;
$color-text-green: #4fae4e;
$color-green: #00c73e;
$color-light-green: #eeffde;
$color-error: #e53935;
$color-warning: #fb8c00;
$color-yellow: #fdd764;
$color-orange: #d08a31;
$color-light-coral: #d08a3133;
$color-white: #ffffff;
$color-black: #000000;
$color-dark-gray: #2e3939;
$color-gray: #c4c9cc;
$color-text-secondary: #707579;
$color-text-secondary-apple: #8a8a90;
$color-text-meta: #686c72;
$color-text-meta-apple: #8c8c91;
$color-borders: #dadce0;
$color-dividers: #c8c6cc;
$color-dividers-android: #E7E7E7;
$color-item-hover: #f4f4f5;
$color-item-active: #ededed;
$color-chat-hover: #f4f4f5;
$color-chat-active: #3390ec;
$color-selection: #3993fb;
$color-message-reaction: #ebf3fd;
$color-message-reaction-hover: #c5def9;
$color-message-reaction-own: #cef0ba;
$color-message-reaction-own-hover: #b5e0a4;
$color-message-reaction-chosen-hover: #1a82ea;
$color-message-reaction-chosen-hover-own: #3f9d4b;
$color-message-non-contact: #cceebf;
$color-message-story-mention-from: #4ef390;
$color-message-story-mention-to: #74bcff;
:root {
--color-background: #{$color-white};
--color-background-compact-menu: #FFFFFFBB;
--color-background-compact-menu-reactions: #FFFFFFEB;
--color-background-compact-menu-hover: #000000B2;
--color-background-menu-separator: #0000001a;
--color-background-selected: #f4f4f5;
--color-background-secondary: #f4f4f5;
--color-background-secondary-accent: #e4e4e5;
--color-background-sidebar: #E4E4E5;
--color-background-own: #{$color-light-green};
--color-background-own-selected: #{color.adjust($color-light-green, $lightness: -10%, $space: hsl)};
--color-text: #{$color-black};
--color-text-rgb: #{toRGB($color-black)};
--color-text-lighter: #{$color-dark-gray};
--color-text-secondary: #{$color-text-secondary};
--color-icon-secondary: #{$color-text-secondary};
--color-text-secondary-rgb: #{toRGB($color-text-secondary)};
--color-text-secondary-apple: #{$color-text-secondary-apple};
--color-text-meta: #{$color-text-meta};
--color-text-meta-rgb: #{toRGB($color-text-meta)};
--color-text-meta-colored: #{$color-text-green};
--color-text-meta-apple: #{$color-text-meta-apple};
--color-text-green: #{$color-text-green};
--color-text-green-rgb: #{toRGB($color-text-green)};
--color-borders: #{$color-borders};
--color-borders-input: #{$color-borders};
--color-borders-alternate: rgba(0, 0, 0, 0.1);
--color-borders-read-story: #C4C9CC;
--color-dividers: #{$color-dividers};
--color-dividers-android: #{$color-dividers-android};
--color-webpage-initial-background: #{$color-dark-gray};
--color-interactive-active: var(--color-primary);
--color-interactive-inactive: rgba(var(--color-text-secondary-rgb), 0.25);
--color-interactive-buffered: rgba(var(--color-text-secondary-rgb), 0.25); // Overlays underlying inactive element
--color-interactive-element-hover: rgba(var(--color-text-secondary-rgb), 0.08);
--color-composer-button: #{$color-text-secondary}CC;
--color-toast-background: #202020CC;
--color-voice-transcribe-button: #e8f3ff;
--color-voice-transcribe-button-own: #cceebf;
--color-primary: #{$color-primary};
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};
--color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
--color-primary-opacity: rgba(var(--color-primary), 0.2);
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
--color-primary-tint: rgba(var(--color-primary), 0.1);
--color-active: #{$color-green};
--color-active-darker: #{color.mix($color-green, $color-black, 84%)};
--color-success: #{$color-green};
--accent-color: var(--color-primary);
--accent-background-color: var(--color-primary-tint);
--accent-background-active-color: var(--color-primary-opacity-hover);
--color-green: #{$color-green};
--color-green-darker: #{color.mix($color-green, $color-black, 84%)};
--color-green-rgb: #{toRGB($color-green)};
--color-error: #{$color-error};
--color-error-shade: #{color.mix($color-error, $color-black, 92%)};
--color-error-rgb: #{toRGB($color-error)};
--color-warning: #{$color-warning};
--color-yellow: #{$color-yellow};
--color-orange: #{$color-orange};
--color-light-coral: #{$color-light-coral};
--color-links: #{$color-links};
--color-own-links: #{$color-white};
--color-placeholders: #{$color-placeholders};
--color-list-icon: #{$color-white};
--color-code: #4a729a;
--color-code-bg: #{rgba($color-text-secondary, 0.08)};
--color-code-own: #3c7940;
--color-code-own-bg: #{rgba($color-text-secondary, 0.08)};
--color-accent-own: #{$color-text-green};
--color-accent-own-rgb: #{toRGB($color-text-green)};
--color-message-meta-own: #{$color-text-green};
--color-message-reaction: #{$color-message-reaction};
--color-message-reaction-hover: #{$color-message-reaction-hover};
--color-message-reaction-own: #{$color-message-reaction-own};
--color-message-reaction-hover-own: #{$color-message-reaction-own-hover};
--color-message-reaction-chosen-hover: #{$color-message-reaction-chosen-hover};
--color-message-reaction-chosen-hover-own: #{$color-message-reaction-chosen-hover-own};
--color-message-non-contact: #{$color-message-non-contact};
--color-message-story-mention-from: #{$color-message-story-mention-from};
--color-message-story-mention-to: #{$color-message-story-mention-to};
--color-reply-hover: #{blend-normal(rgba($color-text-secondary, 0.08), $color-white)};
--color-reply-active: #{blend-normal(rgba($color-text-secondary, 0.16), $color-white)};
--color-reply-own-hover: #{blend-normal(rgba($color-text-green, 0.12), $color-light-green)};
--color-reply-own-active: #{blend-normal(rgba($color-text-green, 0.24), $color-light-green)};
--color-background-own-apple: #dcf8c5;
--color-reply-own-hover-apple: #cbefb7;
--color-reply-own-active-apple: #bae6a8;
--color-white: #{$color-white};
--color-gray: #{$color-gray};
--color-chat-username: #3C7EB0;
--color-chat-hover: #{$color-chat-hover};
--color-chat-active: #{$color-chat-active};
--color-item-hover: #{$color-item-hover};
--color-item-active: #{$color-item-active};
--color-selection-highlight: #{$color-selection};
--color-selection-highlight-emoji: rgba(#{toRGB($color-selection)}, 0.7);
--color-avatar-story-unread-from: #34c578;
--color-avatar-story-unread-to: #3ca3f3;
--color-avatar-story-friend-unread-from: #c9eb38;
--color-avatar-story-friend-unread-to: #09c167;
--color-default-shadow: #72727240;
--color-light-shadow: #7272722b;
--color-skeleton-background: rgba(33, 33, 33, 0.15);
--color-skeleton-foreground: rgba(232, 232, 232, 0.2);
--color-scrollbar: rgba(90, 90, 90, 0.3);
--color-scrollbar-code: rgba(200, 200, 200, 0.3);
--color-telegram-blue: #{$color-primary};
--color-forum-hover-unread-topic: #e9e9e9;
--color-forum-hover-unread-topic-hover: #dcdcdc;
--color-deleted-account: #9eaab5;
--color-archive: #9eaab5;
--stars-gradient: linear-gradient(90deg, #FFAA00 0%, #FFCD3A 100%);
--color-stars: #FFAA00;
--color-heart: #ff3c32;
--color-gift-uncommon: #40A920;
--color-gift-uncommon-bg: rgba(64, 169, 32, 0.15);
--color-gift-rare: #11AABE;
--color-gift-rare-bg: rgba(17, 170, 190, 0.15);
--color-gift-epic: #955CDB;
--color-gift-epic-bg: rgba(149, 92, 219, 0.15);
--color-gift-legendary: #BF7600;
--color-gift-legendary-bg: rgba(191, 118, 0, 0.15);
--color-negative-progress: #CE4C47;
--vh: 1vh;
--border-radius-button: 1rem;
--border-radius-button-tiny: 0.875rem;
--border-radius-modal: 2rem;
--border-radius-toast: 1rem;
--border-radius-island: 1.5rem;
--border-radius-default: 1rem;
--border-radius-default-small: 0.625rem;
--border-radius-default-tiny: 0.375rem;
--border-radius-messages: 0.9375rem;
--border-radius-messages-small: 0.375rem;
--border-radius-forum-avatar: 33.3333%;
--messages-container-width: 45.5rem;
--right-column-width: 26.5rem;
--folders-sidebar-width: 5rem;
--window-controls-width: 0rem;
--header-height: 3.5rem;
--emoji-size: 1.25em;
--custom-emoji-size: var(--emoji-size);
--custom-emoji-border-radius: 0;
--symbol-menu-width: 24rem;
--symbol-menu-height: 22.375rem;
--symbol-menu-footer-height: 3rem;
--scrollbar-width: 0;
--z-overlay-effects: 12000;
--z-modal-confirm: 10500;
--z-reaction-picker: 10200;
--z-portal-menu: 10000;
--z-symbol-menu-modal: 5000;
--z-lock-screen: 3000;
--z-ui-loader-mask: 2000;
--z-notification: 1700;
--z-confetti: 1600;
--z-story-viewer: 1150;
--z-reaction-interaction-effect: 1100;
--z-right-column: 900;
--z-right-column-menu: 950;
--z-header-menu: 990;
--z-header-menu-backdrop: 980;
--z-modal: 1510;
--z-modal-menu: 1600;
--z-resize-grip: 1000;
--z-media-viewer: 1500;
--z-modal-low-priority: 1400;
--z-video-player-controls: 3;
--z-drop-area: 55;
--z-animation-fade: 50;
--z-menu-bubble: 21;
--z-menu-backdrop: 20;
--z-message-effect: 15;
--z-message-highlighted: 14;
--z-forum-panel: 13;
--z-message-context-menu: 13;
--z-scroll-down-button: 12;
--z-local-search: 12;
--z-left-header: 11;
--z-middle-header: 11;
--z-middle-footer: 11;
--z-scroll-notch: 10;
--z-story-ribbon: 10;
--z-country-code-input-group: 10;
--z-message-select-control: 9;
--z-message-select-area: 8;
--z-sticky-date: 9;
--z-register-add-avatar: 5;
--z-media-viewer-head: 3;
--z-symbol-menu-mobile: calc(var(--z-story-viewer) + 1);
--z-resize-handle: 2;
--z-below: -1;
--z-chat-ripple: 6;
--z-chat-float-button: calc(var(--z-chat-ripple) + 1);
--spinner-white-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iI2ZmZmZmZiIvPjwvc3ZnPg==);
--spinner-white-thin-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTEyIDIzQzUuOSAyMyAxIDE4LjEgMSAxMlM1LjkgMSAxMiAxVjBDNS40IDAgMCA1LjQgMCAxMnM1LjQgMTIgMTIgMTIgMTItNS40IDEyLTEyaC0xYzAgNi4xLTQuOSAxMS0xMSAxMXoiLz48L3N2Zz4=);
--spinner-blue-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzRlYTRmNiIvPjwvc3ZnPg==);
--spinner-dark-blue-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzgzNzhEQiIvPjwvc3ZnPg==);
--spinner-black-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzJlMzkzOSIvPjwvc3ZnPg==);
--spinner-green-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzRmYWU0ZSIvPjwvc3ZnPg==);
--spinner-gray-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzcwNzU3OSIvPjwvc3ZnPg==);
--spinner-yellow-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iI0ZERDc2NCIvPjwvc3ZnPg==);
--premium-gradient: linear-gradient(84.4deg, #6C93FF -4.85%, #976FFF 51.72%, #DF69D1 110.7%);
--layer-blackout-opacity: 0.1;
--layer-transition: 300ms cubic-bezier(0.33, 1, 0.68, 1);
--layer-transition-behind: 300ms cubic-bezier(0.33, 1, 0.68, 1);
--slide-transition: 300ms cubic-bezier(0.25, 1, 0.5, 1);
--select-transition: 200ms ease-out;
--chat-transform-transition: 0.2s ease-out;
--safe-area-top: env(safe-area-inset-top);
--safe-area-right: env(safe-area-inset-right);
--safe-area-bottom: env(safe-area-inset-bottom);
--safe-area-left: env(safe-area-inset-left);
--picker-title-shift: 1rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--middle-header-panes-height: 0px;
body.is-ios {
--layer-transition: 650ms cubic-bezier(0.22, 1, 0.36, 1);
--layer-transition-behind: 650ms cubic-bezier(0.33, 1, 0.68, 1);
--slide-transition: 450ms cubic-bezier(0.25, 1, 0.5, 1);
}
body.is-android {
--slide-transition: 350ms cubic-bezier(0.16, 1, 0.3, 1);
}
@media (min-width: 1276px) and (max-width: 1920px) {
--right-column-width: 25vw;
}
@media (min-width: 1921px) {
--messages-container-width: 50vw;
}
@media (max-width: 600px) {
--right-column-width: 100vw;
--symbol-menu-width: 100vw;
--symbol-menu-height: 17.6875rem;
}
}
}

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