diff --git a/backend/migrations/versions/a9c3e7f1d2b4_media_unique_id.py b/backend/migrations/versions/a9c3e7f1d2b4_media_unique_id.py
new file mode 100644
index 0000000..853a3a5
--- /dev/null
+++ b/backend/migrations/versions/a9c3e7f1d2b4_media_unique_id.py
@@ -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")
diff --git a/backend/migrations/versions/f7a2c9e1b3d5_media_versions.py b/backend/migrations/versions/f7a2c9e1b3d5_media_versions.py
new file mode 100644
index 0000000..42c34a5
--- /dev/null
+++ b/backend/migrations/versions/f7a2c9e1b3d5_media_versions.py
@@ -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")
diff --git a/backend/src/api/app.py b/backend/src/api/app.py
index 1bef551..420da78 100644
--- a/backend/src/api/app.py
+++ b/backend/src/api/app.py
@@ -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)
diff --git a/backend/src/api/routers/accounts.py b/backend/src/api/routers/accounts.py
new file mode 100644
index 0000000..8f5c5af
--- /dev/null
+++ b/backend/src/api/routers/accounts.py
@@ -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)
diff --git a/backend/src/api/routers/avatars.py b/backend/src/api/routers/avatars.py
new file mode 100644
index 0000000..1121bea
--- /dev/null
+++ b/backend/src/api/routers/avatars.py
@@ -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"
+ )
diff --git a/backend/src/api/routers/chats.py b/backend/src/api/routers/chats.py
index cbf80c9..996f774 100644
--- a/backend/src/api/routers/chats.py
+++ b/backend/src/api/routers/chats.py
@@ -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
diff --git a/backend/src/api/routers/media.py b/backend/src/api/routers/media.py
index 318d6a1..ceece66 100644
--- a/backend/src/api/routers/media.py
+++ b/backend/src/api/routers/media.py
@@ -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],
diff --git a/backend/src/api/routers/peers.py b/backend/src/api/routers/peers.py
index fc7b152..25d0ef1 100644
--- a/backend/src/api/routers/peers.py
+++ b/backend/src/api/routers/peers.py
@@ -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
diff --git a/backend/src/userbot/handlers/edits.py b/backend/src/userbot/handlers/edits.py
index 16396d5..792041a 100644
--- a/backend/src/userbot/handlers/edits.py
+++ b/backend/src/userbot/handlers/edits.py
@@ -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
diff --git a/backend/src/userbot/modules/avatars/__init__.py b/backend/src/userbot/modules/avatars/__init__.py
index df90a41..ecf6e1e 100644
--- a/backend/src/userbot/modules/avatars/__init__.py
+++ b/backend/src/userbot/modules/avatars/__init__.py
@@ -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"]
diff --git a/backend/src/userbot/modules/avatars/repository.py b/backend/src/userbot/modules/avatars/repository.py
index e9ec5ae..29db57c 100644
--- a/backend/src/userbot/modules/avatars/repository.py
+++ b/backend/src/userbot/modules/avatars/repository.py
@@ -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,
+ )
diff --git a/backend/src/userbot/modules/capture/context.py b/backend/src/userbot/modules/capture/context.py
index 02e8e4a..d566a26 100644
--- a/backend/src/userbot/modules/capture/context.py
+++ b/backend/src/userbot/modules/capture/context.py
@@ -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:
diff --git a/backend/src/userbot/modules/capture/identity.py b/backend/src/userbot/modules/capture/identity.py
new file mode 100644
index 0000000..69244c3
--- /dev/null
+++ b/backend/src/userbot/modules/capture/identity.py
@@ -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)
diff --git a/backend/src/userbot/modules/capture/message.py b/backend/src/userbot/modules/capture/message.py
index 92eed8f..5c8f7c7 100644
--- a/backend/src/userbot/modules/capture/message.py
+++ b/backend/src/userbot/modules/capture/message.py
@@ -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:
diff --git a/backend/src/userbot/modules/capture/repository.py b/backend/src/userbot/modules/capture/repository.py
index dd02270..c813ea4 100644
--- a/backend/src/userbot/modules/capture/repository.py
+++ b/backend/src/userbot/modules/capture/repository.py
@@ -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(
diff --git a/backend/src/userbot/modules/groups/repository.py b/backend/src/userbot/modules/groups/repository.py
index 588939c..ac1d3fb 100644
--- a/backend/src/userbot/modules/groups/repository.py
+++ b/backend/src/userbot/modules/groups/repository.py
@@ -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,
diff --git a/backend/src/userbot/modules/jobs/handlers/__init__.py b/backend/src/userbot/modules/jobs/handlers/__init__.py
index dc36fd6..7aad1cd 100644
--- a/backend/src/userbot/modules/jobs/handlers/__init__.py
+++ b/backend/src/userbot/modules/jobs/handlers/__init__.py
@@ -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"]
diff --git a/backend/src/userbot/modules/jobs/handlers/enrich_chat.py b/backend/src/userbot/modules/jobs/handlers/enrich_chat.py
new file mode 100644
index 0000000..ba16601
--- /dev/null
+++ b/backend/src/userbot/modules/jobs/handlers/enrich_chat.py
@@ -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)
diff --git a/backend/src/userbot/modules/jobs/handlers/fetch_avatar.py b/backend/src/userbot/modules/jobs/handlers/fetch_avatar.py
new file mode 100644
index 0000000..78bc91d
--- /dev/null
+++ b/backend/src/userbot/modules/jobs/handlers/fetch_avatar.py
@@ -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),
+ )
diff --git a/backend/src/userbot/modules/media/__init__.py b/backend/src/userbot/modules/media/__init__.py
index b1ed28d..df928a3 100644
--- a/backend/src/userbot/modules/media/__init__.py
+++ b/backend/src/userbot/modules/media/__init__.py
@@ -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"]
diff --git a/backend/src/userbot/modules/media/downloader.py b/backend/src/userbot/modules/media/downloader.py
index 277d08e..095e833 100644
--- a/backend/src/userbot/modules/media/downloader.py
+++ b/backend/src/userbot/modules/media/downloader.py
@@ -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,
)
diff --git a/backend/src/userbot/modules/profiles/__init__.py b/backend/src/userbot/modules/profiles/__init__.py
index 4ca869f..59a59a6 100644
--- a/backend/src/userbot/modules/profiles/__init__.py
+++ b/backend/src/userbot/modules/profiles/__init__.py
@@ -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",
+]
diff --git a/backend/src/userbot/modules/profiles/parse.py b/backend/src/userbot/modules/profiles/parse.py
index cae378e..88b01bb 100644
--- a/backend/src/userbot/modules/profiles/parse.py
+++ b/backend/src/userbot/modules/profiles/parse.py
@@ -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
diff --git a/backend/src/utils/env.py b/backend/src/utils/env.py
index d396635..8b2ec26 100644
--- a/backend/src/utils/env.py
+++ b/backend/src/utils/env.py
@@ -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):
diff --git a/backend/src/utils/read/accounts.py b/backend/src/utils/read/accounts.py
new file mode 100644
index 0000000..6bb6a56
--- /dev/null
+++ b/backend/src/utils/read/accounts.py
@@ -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]
diff --git a/backend/src/utils/read/avatars.py b/backend/src/utils/read/avatars.py
new file mode 100644
index 0000000..4ba375c
--- /dev/null
+++ b/backend/src/utils/read/avatars.py
@@ -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
diff --git a/backend/src/utils/read/chats.py b/backend/src/utils/read/chats.py
index 2e005b6..1c25b40 100644
--- a/backend/src/utils/read/chats.py
+++ b/backend/src/utils/read/chats.py
@@ -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(
diff --git a/backend/src/utils/read/media.py b/backend/src/utils/read/media.py
index 3787fad..5aabe2e 100644
--- a/backend/src/utils/read/media.py
+++ b/backend/src/utils/read/media.py
@@ -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
diff --git a/backend/src/utils/read/message_view.py b/backend/src/utils/read/message_view.py
new file mode 100644
index 0000000..5a8ede2
--- /dev/null
+++ b/backend/src/utils/read/message_view.py
@@ -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)
diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py
index 17ac3e9..cd16aa1 100644
--- a/backend/src/utils/read/models.py
+++ b/backend/src/utils/read/models.py
@@ -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
diff --git a/backend/src/utils/read/peers.py b/backend/src/utils/read/peers.py
index b70b34f..d453cbc 100644
--- a/backend/src/utils/read/peers.py
+++ b/backend/src/utils/read/peers.py
@@ -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]:
diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example
index 32fab08..f734eb4 100644
--- a/docker-compose.override.yml.example
+++ b/docker-compose.override.yml.example
@@ -6,3 +6,7 @@ services:
api:
ports:
- "127.0.0.1:8080:8080"
+
+ frontend-dev:
+ ports:
+ - "127.0.0.1:5173:5173"
diff --git a/docker-compose.yml b/docker-compose.yml
index bc381f4..3742ca1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/frontend/biome.jsonc b/frontend/biome.jsonc
index bc94ca5..b705622 100644
--- a/frontend/biome.jsonc
+++ b/frontend/biome.jsonc
@@ -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 }
}
]
}
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 17f8dd6..243b321 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -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=="],
}
}
diff --git a/frontend/package.json b/frontend/package.json
index b8907bb..70a2d73 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
+ }
}
diff --git a/frontend/src/app.html b/frontend/src/app.html
index 6a2bb58..cb9d355 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -4,6 +4,16 @@
+
%sveltekit.head%
diff --git a/frontend/src/lib/actions/ripple.ts b/frontend/src/lib/actions/ripple.ts
new file mode 100644
index 0000000..3bd707d
--- /dev/null
+++ b/frontend/src/lib/actions/ripple.ts
@@ -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(".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);
+ },
+ };
+}
diff --git a/frontend/src/lib/actions/visible.ts b/frontend/src/lib/actions/visible.ts
new file mode 100644
index 0000000..24d801c
--- /dev/null
+++ b/frontend/src/lib/actions/visible.ts
@@ -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();
+ },
+ };
+}
diff --git a/frontend/src/lib/api/avatars.ts b/frontend/src/lib/api/avatars.ts
new file mode 100644
index 0000000..42afd29
--- /dev/null
+++ b/frontend/src/lib/api/avatars.ts
@@ -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();
+const missing = new Set();
+const inflight = new Map>();
+
+function cacheKey(account: number, kind: AvatarKind, id: number): string {
+ return `${account}:${kind}:${id}`;
+}
+
+function authHeaders(): Record {
+ return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+async function fetchAvatar(
+ account: number,
+ kind: AvatarKind,
+ id: number,
+ key: string,
+ retry: boolean
+): Promise {
+ 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 {
+ 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;
+}
diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts
new file mode 100644
index 0000000..8013f17
--- /dev/null
+++ b/frontend/src/lib/api/client.ts
@@ -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;
+}
+
+function buildQuery(
+ query: Record | 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 {
+ return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
+}
+
+async function handleError(response: Response): Promise {
+ 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(
+ path: string,
+ options: RequestOptions = {}
+): Promise {
+ const { method = "GET", query, body, account = false } = options;
+ const headers: Record = 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;
+}
+
+export type MediaResult =
+ | { state: "ready"; url: string; mime: string | null }
+ | { state: "not-downloaded" }
+ | { state: "missing" };
+
+export async function requestMedia(mediaId: number): Promise {
+ 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 {
+ 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"),
+ };
+}
diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts
new file mode 100644
index 0000000..5a8a294
--- /dev/null
+++ b/frontend/src/lib/api/endpoints.ts
@@ -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 {
+ return request("/accounts");
+}
+
+export function listChats(page: Page = {}): Promise {
+ return request("/chats", { account: true, query: { ...page } });
+}
+
+export function listFolders(): Promise {
+ return request("/folders", { account: true });
+}
+
+export function listMessages(
+ chatId: number,
+ options: Page & { include_deleted?: boolean } = {}
+): Promise {
+ return request(`/chats/${chatId}/messages`, {
+ account: true,
+ query: { ...options },
+ });
+}
+
+export function listMessageVersions(
+ chatId: number,
+ messageId: number
+): Promise {
+ return request(
+ `/chats/${chatId}/messages/${messageId}/versions`,
+ { account: true }
+ );
+}
+
+export function listDeleted(
+ options: Page & { chat_id?: number } = {}
+): Promise {
+ return request("/deleted", {
+ account: true,
+ query: { ...options },
+ });
+}
+
+export function getPeer(peerId: number): Promise {
+ return request(`/peers/${peerId}`, { account: true });
+}
+
+export function getPeers(ids: number[]): Promise {
+ if (ids.length === 0) {
+ return Promise.resolve([]);
+ }
+ return request("/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 {
+ return request(`/jobs/${jobId}`, { account: true });
+}
+
+export function getMediaVersions(
+ chatId: number,
+ messageId: number
+): Promise {
+ return request(`/media/versions/${chatId}/${messageId}`, {
+ account: true,
+ });
+}
+
+export function getMediaMeta(mediaId: number): Promise {
+ return request(`/media/${mediaId}/meta`);
+}
+
+export function getMessageMedia(
+ chatId: number,
+ messageId: number
+): Promise {
+ return request(`/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,
+ },
+ });
+}
diff --git a/frontend/src/lib/api/media.ts b/frontend/src/lib/api/media.ts
new file mode 100644
index 0000000..5b9c845
--- /dev/null
+++ b/frontend/src/lib/api/media.ts
@@ -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();
+const inflight = new Map>();
+
+function cacheKey(account: number, chatId: number, messageId: number): string {
+ return `${account}:${chatId}:${messageId}`;
+}
+
+async function resolve(
+ chatId: number,
+ messageId: number
+): Promise {
+ let meta: Awaited>;
+ 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();
+const byIdInflight = new Map>();
+
+async function resolveById(media: MediaRef): Promise {
+ 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 {
+ const account = accounts.selectedId;
+ if (account === null || media.id === null) {
+ return Promise.resolve({ 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 {
+ const account = accounts.selectedId;
+ if (account === null) {
+ return Promise.resolve({ 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;
+}
diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
new file mode 100644
index 0000000..44ce2e8
--- /dev/null
+++ b/frontend/src/lib/api/types.ts
@@ -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;
+ updated_at: string;
+}
+
+export interface Alert {
+ account_id: number;
+ created_at: string;
+ id: number;
+ payload: Record;
+ seen: boolean;
+ ts: string;
+ watch_id: number;
+}
+
+export interface JobView {
+ account_id: number;
+ attempts: number;
+ created_at: string;
+ cursor: Record | null;
+ error: string | null;
+ finished_at: string | null;
+ flood_waits: number;
+ id: number;
+ kind: string;
+ params: Record;
+ progress: Record;
+ started_at: string | null;
+ status: JobStatus;
+}
diff --git a/frontend/src/lib/components/AccountSwitcher.svelte b/frontend/src/lib/components/AccountSwitcher.svelte
new file mode 100644
index 0000000..14f715b
--- /dev/null
+++ b/frontend/src/lib/components/AccountSwitcher.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+ {#if current}
+
+ {accountName(current)}
+ {:else}
+ No account
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/ChatHeader.svelte b/frontend/src/lib/components/ChatHeader.svelte
new file mode 100644
index 0000000..357930a
--- /dev/null
+++ b/frontend/src/lib/components/ChatHeader.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ChatList.svelte b/frontend/src/lib/components/ChatList.svelte
new file mode 100644
index 0000000..e056986
--- /dev/null
+++ b/frontend/src/lib/components/ChatList.svelte
@@ -0,0 +1,95 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ChatListItem.svelte b/frontend/src/lib/components/ChatListItem.svelte
new file mode 100644
index 0000000..13bd8a2
--- /dev/null
+++ b/frontend/src/lib/components/ChatListItem.svelte
@@ -0,0 +1,163 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/EntityText.svelte b/frontend/src/lib/components/EntityText.svelte
new file mode 100644
index 0000000..da8a453
--- /dev/null
+++ b/frontend/src/lib/components/EntityText.svelte
@@ -0,0 +1,179 @@
+
+
+{#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"}
+ {@render tree(node.children)}
+ {:else if type === "italic"}
+ {@render tree(node.children)}
+ {:else if type === "underline"}
+ {@render tree(node.children)}
+ {:else if type === "strikethrough"}
+ {@render tree(node.children)}
+ {:else if type === "spoiler"}
+ {@const key = spoilerKey(node.entity)}
+
+ {:else if type === "code"}
+ {@render tree(node.children)}
+ {:else if type === "pre"}
+ {#if node.entity.language}{node.entity.language}{/if}{@render tree(node.children)}
+ {:else if type === "blockquote"}
+ {@render tree(node.children)}
+ {:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"}
+ {@render tree(node.children)}
+ {:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"}
+ {@render tree(node.children)}
+ {:else}
+ {@render tree(node.children)}
+ {/if}
+{/snippet}
+
+ 0} class:jumbo-1={jumbo === 1}>
+ {@render tree(nodes)}
+
+
+
diff --git a/frontend/src/lib/components/FolderTabs.svelte b/frontend/src/lib/components/FolderTabs.svelte
new file mode 100644
index 0000000..f81c587
--- /dev/null
+++ b/frontend/src/lib/components/FolderTabs.svelte
@@ -0,0 +1,171 @@
+
+
+
+ {#each tabs as tab (tab.id)}
+
+ {/each}
+
+
+ {#each tabs as tab (tab.id)}
+
+ {/each}
+
+
+
+
diff --git a/frontend/src/lib/components/ForwardHeader.svelte b/frontend/src/lib/components/ForwardHeader.svelte
new file mode 100644
index 0000000..886e8e0
--- /dev/null
+++ b/frontend/src/lib/components/ForwardHeader.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/LoginScreen.svelte b/frontend/src/lib/components/LoginScreen.svelte
new file mode 100644
index 0000000..3ee7ece
--- /dev/null
+++ b/frontend/src/lib/components/LoginScreen.svelte
@@ -0,0 +1,124 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/MediaAlbum.svelte b/frontend/src/lib/components/MediaAlbum.svelte
new file mode 100644
index 0000000..37b6b73
--- /dev/null
+++ b/frontend/src/lib/components/MediaAlbum.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/MediaVersionThumb.svelte b/frontend/src/lib/components/MediaVersionThumb.svelte
new file mode 100644
index 0000000..8110fa7
--- /dev/null
+++ b/frontend/src/lib/components/MediaVersionThumb.svelte
@@ -0,0 +1,117 @@
+
+
+
+ {#if result === null}
+
+ {:else if result.state === "ready" && vk === "image"}
+
+
+
+ {:else if result.state === "ready" && vk === "video"}
+
+
+
+
+ {:else if result.state === "ready"}
+
+
+ {version.kind}
+
+ {:else}
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/MediaViewer.svelte b/frontend/src/lib/components/MediaViewer.svelte
new file mode 100644
index 0000000..af315c9
--- /dev/null
+++ b/frontend/src/lib/components/MediaViewer.svelte
@@ -0,0 +1,317 @@
+
+
+
+
+
+
+
+
+ {kind || "Media"}
+
+
+
+ {#if hasNav}
+ {index + 1} / {items.length}
+ {/if}
+
+ {#if loading}
+
+ {:else if result?.state === "ready" && isImage}
+

+ {:else if result?.state === "ready" && isVideo}
+
+
+
+ {:else if result?.state === "ready" && isAudio}
+
+
+ {:else if result?.state === "ready"}
+
+
+ Download file
+
+ {:else if result?.state === "not-downloaded"}
+
+ {:else if result?.state === "missing"}
+
Media not found.
+ {/if}
+
+ {#if hasNav}
+
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/src/lib/components/MessageBubble.svelte b/frontend/src/lib/components/MessageBubble.svelte
new file mode 100644
index 0000000..df45acf
--- /dev/null
+++ b/frontend/src/lib/components/MessageBubble.svelte
@@ -0,0 +1,262 @@
+
+
+
+ {#if isGroupChat}
+
+ {#if lastInGroup && avatarId !== null}
+
+ {/if}
+
+ {/if}
+
+
+ {#if showName}
+
{senderName}
+ {/if}
+ {#if message.forward}
+
+ {/if}
+ {#if message.reply}
+
+ {/if}
+ {#if deleted}
+
+
+ deleted
+
+ {/if}
+ {#if message.media.length > 1}
+
+ {:else if message.has_media}
+
onmedia(0)} />
+ {/if}
+ {#if hasText}
+
+
+
+ {:else if !(message.has_media || deleted)}
+ (no text)
+ {/if}
+
+ {#if lastInGroup}
+
+ {/if}
+
+
+
+
diff --git a/frontend/src/lib/components/MessageList.svelte b/frontend/src/lib/components/MessageList.svelte
new file mode 100644
index 0000000..476fa03
--- /dev/null
+++ b/frontend/src/lib/components/MessageList.svelte
@@ -0,0 +1,353 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/MessageMedia.svelte b/frontend/src/lib/components/MessageMedia.svelte
new file mode 100644
index 0000000..256533e
--- /dev/null
+++ b/frontend/src/lib/components/MessageMedia.svelte
@@ -0,0 +1,321 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/MessageMeta.svelte b/frontend/src/lib/components/MessageMeta.svelte
new file mode 100644
index 0000000..aa8e124
--- /dev/null
+++ b/frontend/src/lib/components/MessageMeta.svelte
@@ -0,0 +1,55 @@
+
+
+
+ {#if message.edited_at}
+
+ {/if}
+ {formatTime(message.date)}
+
+
+
diff --git a/frontend/src/lib/components/MessageVersions.svelte b/frontend/src/lib/components/MessageVersions.svelte
new file mode 100644
index 0000000..008cf0a
--- /dev/null
+++ b/frontend/src/lib/components/MessageVersions.svelte
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+ {#if loading}
+
+ {:else if versions.length === 0 && mediaVersions.length === 0}
+
No versions recorded.
+ {:else}
+ {#if mediaVersions.length > 0}
+
+ {/if}
+ {#each versions as version, index (version.observed_at)}
+
+
+ Version {index + 1}
+ {formatFull(version.observed_at)}
+
+
{version.text ?? "(no text)"}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/ReplyHeader.svelte b/frontend/src/lib/components/ReplyHeader.svelte
new file mode 100644
index 0000000..f9ee36e
--- /dev/null
+++ b/frontend/src/lib/components/ReplyHeader.svelte
@@ -0,0 +1,110 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/media/AlbumTile.svelte b/frontend/src/lib/components/media/AlbumTile.svelte
new file mode 100644
index 0000000..728390a
--- /dev/null
+++ b/frontend/src/lib/components/media/AlbumTile.svelte
@@ -0,0 +1,106 @@
+
+
+
+ {#if ready && isVideo}
+
+ {:else if ready}
+
+ {:else if inline?.state === "not-downloaded"}
+
+ {:else if loaded}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/media/AudioFile.svelte b/frontend/src/lib/components/media/AudioFile.svelte
new file mode 100644
index 0000000..71cf34f
--- /dev/null
+++ b/frontend/src/lib/components/media/AudioFile.svelte
@@ -0,0 +1,153 @@
+
+
+
+
+
+
{title}
+
+
+
+ {formatDuration(currentTime)}
+ / {formatDuration(duration)}
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/media/VideoNote.svelte b/frontend/src/lib/components/media/VideoNote.svelte
new file mode 100644
index 0000000..6d439ea
--- /dev/null
+++ b/frontend/src/lib/components/media/VideoNote.svelte
@@ -0,0 +1,203 @@
+
+
+
+
+ {#if transcript}
+
+ {#if showTranscript}
+
{transcript}
+ {/if}
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/media/VoiceMessage.svelte b/frontend/src/lib/components/media/VoiceMessage.svelte
new file mode 100644
index 0000000..3a91b68
--- /dev/null
+++ b/frontend/src/lib/components/media/VoiceMessage.svelte
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+ {#each peaks as peak, i (i)}
+
+ {/each}
+
+
{formatDuration(elapsed)}
+
+ {#if transcript}
+
+ {/if}
+
+
+
+ {#if transcript && showTranscript}
+
{transcript}
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/ui/Avatar.svelte b/frontend/src/lib/components/ui/Avatar.svelte
new file mode 100644
index 0000000..b07514f
--- /dev/null
+++ b/frontend/src/lib/components/ui/Avatar.svelte
@@ -0,0 +1,104 @@
+
+
+
+ {#if url}
+

+ {:else}
+
{initials}
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/ui/Button.svelte b/frontend/src/lib/components/ui/Button.svelte
new file mode 100644
index 0000000..dcb0c07
--- /dev/null
+++ b/frontend/src/lib/components/ui/Button.svelte
@@ -0,0 +1,211 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/EmptyState.svelte b/frontend/src/lib/components/ui/EmptyState.svelte
new file mode 100644
index 0000000..bd596e1
--- /dev/null
+++ b/frontend/src/lib/components/ui/EmptyState.svelte
@@ -0,0 +1,41 @@
+
+
+
+
{title}
+ {#if description}
+
{description}
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/ui/Icon.svelte b/frontend/src/lib/components/ui/Icon.svelte
new file mode 100644
index 0000000..5eace4d
--- /dev/null
+++ b/frontend/src/lib/components/ui/Icon.svelte
@@ -0,0 +1,15 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/ListItem.svelte b/frontend/src/lib/components/ui/ListItem.svelte
new file mode 100644
index 0000000..e9eb50c
--- /dev/null
+++ b/frontend/src/lib/components/ui/ListItem.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/Skeleton.svelte b/frontend/src/lib/components/ui/Skeleton.svelte
new file mode 100644
index 0000000..6992ac4
--- /dev/null
+++ b/frontend/src/lib/components/ui/Skeleton.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/Spinner.svelte b/frontend/src/lib/components/ui/Spinner.svelte
new file mode 100644
index 0000000..00c1df5
--- /dev/null
+++ b/frontend/src/lib/components/ui/Spinner.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/ToastHost.svelte b/frontend/src/lib/components/ui/ToastHost.svelte
new file mode 100644
index 0000000..77e6cd3
--- /dev/null
+++ b/frontend/src/lib/components/ui/ToastHost.svelte
@@ -0,0 +1,57 @@
+
+
+
+ {#each toasts.items as toast (toast.id)}
+
+ {/each}
+
+
+
diff --git a/frontend/src/lib/format/datetime.ts b/frontend/src/lib/format/datetime.ts
new file mode 100644
index 0000000..8eb0254
--- /dev/null
+++ b/frontend/src/lib/format/datetime.ts
@@ -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);
+}
diff --git a/frontend/src/lib/format/duration.ts b/frontend/src/lib/format/duration.ts
new file mode 100644
index 0000000..0435305
--- /dev/null
+++ b/frontend/src/lib/format/duration.ts
@@ -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")}`;
+}
diff --git a/frontend/src/lib/format/entities.ts b/frontend/src/lib/format/entities.ts
new file mode 100644
index 0000000..e6058ed
--- /dev/null
+++ b/frontend/src/lib/format/entities.ts
@@ -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;
+}
diff --git a/frontend/src/lib/format/folders.ts b/frontend/src/lib/format/folders.ts
new file mode 100644
index 0000000..9811b4f
--- /dev/null
+++ b/frontend/src/lib/format/folders.ts
@@ -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);
+}
diff --git a/frontend/src/lib/format/media.ts b/frontend/src/lib/format/media.ts
new file mode 100644
index 0000000..db4c13c
--- /dev/null
+++ b/frontend/src/lib/format/media.ts
@@ -0,0 +1,25 @@
+const MEDIA_KIND_LABELS: Record = {
+ 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";
+}
diff --git a/frontend/src/lib/format/peer.ts b/frontend/src/lib/format/peer.ts
new file mode 100644
index 0000000..5a39d4b
--- /dev/null
+++ b/frontend/src/lib/format/peer.ts
@@ -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();
+}
diff --git a/frontend/src/lib/media/playback.ts b/frontend/src/lib/media/playback.ts
new file mode 100644
index 0000000..5b7a864
--- /dev/null
+++ b/frontend/src/lib/media/playback.ts
@@ -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;
+ }
+}
diff --git a/frontend/src/lib/media/waveform.ts b/frontend/src/lib/media/waveform.ts
new file mode 100644
index 0000000..a0438e4
--- /dev/null
+++ b/frontend/src/lib/media/waveform.ts
@@ -0,0 +1,36 @@
+const BARS = 48;
+const MIN_BAR = 0.08;
+
+export async function computeWaveform(
+ url: string,
+ bars = BARS
+): Promise {
+ 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);
+}
diff --git a/frontend/src/lib/stores/accounts.svelte.ts b/frontend/src/lib/stores/accounts.svelte.ts
new file mode 100644
index 0000000..8c4fe04
--- /dev/null
+++ b/frontend/src/lib/stores/accounts.svelte.ts
@@ -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([]);
+ let selectedId = $state(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();
diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts
new file mode 100644
index 0000000..74ca72a
--- /dev/null
+++ b/frontend/src/lib/stores/auth.svelte.ts
@@ -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(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();
diff --git a/frontend/src/lib/stores/chats.svelte.ts b/frontend/src/lib/stores/chats.svelte.ts
new file mode 100644
index 0000000..5a574d6
--- /dev/null
+++ b/frontend/src/lib/stores/chats.svelte.ts
@@ -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([]);
+ let loaded = $state(false);
+ let loading = $state(false);
+ let revision = $state(0);
+ let account: number | null = null;
+ const enriched = new Set();
+
+ 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();
diff --git a/frontend/src/lib/stores/folders.svelte.ts b/frontend/src/lib/stores/folders.svelte.ts
new file mode 100644
index 0000000..0cfd330
--- /dev/null
+++ b/frontend/src/lib/stores/folders.svelte.ts
@@ -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([]);
+ let loaded = $state(false);
+ let loading = $state(false);
+ let selectedId = $state(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();
diff --git a/frontend/src/lib/stores/peers.svelte.ts b/frontend/src/lib/stores/peers.svelte.ts
new file mode 100644
index 0000000..1a9f819
--- /dev/null
+++ b/frontend/src/lib/stores/peers.svelte.ts
@@ -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>({});
+ const requested = new Set();
+ const pending = new Set();
+ let account: number | null = null;
+ let timer: ReturnType | 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) {
+ 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();
diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts
new file mode 100644
index 0000000..bb37200
--- /dev/null
+++ b/frontend/src/lib/stores/theme.svelte.ts
@@ -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(readPreference());
+ let systemDark = $state(prefersDark());
+
+ function systemResolved(): Resolved {
+ return systemDark ? "dark" : "light";
+ }
+
+ const resolved = $derived(
+ 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();
diff --git a/frontend/src/lib/stores/toasts.svelte.ts b/frontend/src/lib/stores/toasts.svelte.ts
new file mode 100644
index 0000000..c700569
--- /dev/null
+++ b/frontend/src/lib/stores/toasts.svelte.ts
@@ -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([]);
+ 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();
diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts
new file mode 100644
index 0000000..549cce8
--- /dev/null
+++ b/frontend/src/lib/stores/ui.svelte.ts
@@ -0,0 +1,39 @@
+export type RightPanel =
+ | "profile"
+ | "search"
+ | "versions"
+ | "reactions"
+ | "links"
+ | "annotations"
+ | "jobs"
+ | "presence"
+ | "stories"
+ | "policy";
+
+function createUi() {
+ let rightPanel = $state(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();
diff --git a/frontend/src/lib/styles/dark-theme.scss b/frontend/src/lib/styles/dark-theme.scss
new file mode 100644
index 0000000..64776ca
--- /dev/null
+++ b/frontend/src/lib/styles/dark-theme.scss
@@ -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;
+}
diff --git a/frontend/src/lib/styles/forms.scss b/frontend/src/lib/styles/forms.scss
new file mode 100644
index 0000000..e036773
--- /dev/null
+++ b/frontend/src/lib/styles/forms.scss
@@ -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;
+ }
+ }
+}
diff --git a/frontend/src/lib/styles/global.scss b/frontend/src/lib/styles/global.scss
new file mode 100644
index 0000000..a6d44a4
--- /dev/null
+++ b/frontend/src/lib/styles/global.scss
@@ -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);
+ }
+}
diff --git a/frontend/src/lib/styles/icons.css b/frontend/src/lib/styles/icons.css
new file mode 100644
index 0000000..430759b
--- /dev/null
+++ b/frontend/src/lib/styles/icons.css
@@ -0,0 +1,1044 @@
+@font-face {
+ font-family: "icons";
+ font-weight: normal;
+ font-style: normal;
+ font-display: block;
+ src:
+ url("./icons.woff2") format("woff2"),
+ url("./icons.woff") format("woff");
+}
+
+.icon-char::before {
+ display: block;
+ width: 1em;
+ font-family: Roboto, "Helvetica Neue", sans-serif;
+ text-align: center;
+ content: attr(data-char);
+}
+
+.icon-placeholder::before {
+ display: block;
+ width: 1em;
+ content: "";
+}
+
+.icon-active-sessions::before {
+ content: "\f101";
+}
+.icon-add::before {
+ content: "\f102";
+}
+.icon-add-caption::before {
+ content: "\f103";
+}
+.icon-add-filled::before {
+ content: "\f104";
+}
+.icon-add-one-badge::before {
+ content: "\f105";
+}
+.icon-add-user::before {
+ content: "\f106";
+}
+.icon-add-user-filled::before {
+ content: "\f107";
+}
+.icon-admin::before {
+ content: "\f108";
+}
+.icon-ai::before {
+ content: "\f109";
+}
+.icon-ai-edit::before {
+ content: "\f10a";
+}
+.icon-ai-fix::before {
+ content: "\f10b";
+}
+.icon-allow-share::before {
+ content: "\f10c";
+}
+.icon-allow-speak::before {
+ content: "\f10d";
+}
+.icon-animals::before {
+ content: "\f10e";
+}
+.icon-animations::before {
+ content: "\f10f";
+}
+.icon-archive::before {
+ content: "\f110";
+}
+.icon-archive-filled::before {
+ content: "\f111";
+}
+.icon-archive-from-main::before {
+ content: "\f112";
+}
+.icon-archive-to-main::before {
+ content: "\f113";
+}
+.icon-arrow-down::before {
+ content: "\f114";
+}
+.icon-arrow-down-circle::before {
+ content: "\f115";
+}
+.icon-arrow-left::before {
+ content: "\f116";
+}
+.icon-arrow-right::before {
+ content: "\f117";
+}
+.icon-ask-support::before {
+ content: "\f118";
+}
+.icon-attach::before {
+ content: "\f119";
+}
+.icon-auction::before {
+ content: "\f11a";
+}
+.icon-auction-drop::before {
+ content: "\f11b";
+}
+.icon-auction-filled::before {
+ content: "\f11c";
+}
+.icon-auction-next-round::before {
+ content: "\f11d";
+}
+.icon-author-hidden::before {
+ content: "\f11e";
+}
+.icon-avatar-archived-chats::before {
+ content: "\f11f";
+}
+.icon-avatar-deleted-account::before {
+ content: "\f120";
+}
+.icon-avatar-saved-messages::before {
+ content: "\f121";
+}
+.icon-bold::before {
+ content: "\f122";
+}
+.icon-boost::before {
+ content: "\f123";
+}
+.icon-boost-craft-chance::before {
+ content: "\f124";
+}
+.icon-boost-outline::before {
+ content: "\f125";
+}
+.icon-boostcircle::before {
+ content: "\f126";
+}
+.icon-boosts::before {
+ content: "\f127";
+}
+.icon-bot-command::before {
+ content: "\f128";
+}
+.icon-bot-commands-filled::before {
+ content: "\f129";
+}
+.icon-bots::before {
+ content: "\f12a";
+}
+.icon-brush::before {
+ content: "\f12b";
+}
+.icon-bug::before {
+ content: "\f12c";
+}
+.icon-calendar::before {
+ content: "\f12d";
+}
+.icon-calendar-filter::before {
+ content: "\f12e";
+}
+.icon-camera::before {
+ content: "\f12f";
+}
+.icon-camera-add::before {
+ content: "\f130";
+}
+.icon-car::before {
+ content: "\f131";
+}
+.icon-card::before {
+ content: "\f132";
+}
+.icon-cash-circle::before {
+ content: "\f133";
+}
+.icon-channel::before {
+ content: "\f134";
+}
+.icon-channel-filled::before {
+ content: "\f135";
+}
+.icon-channelviews::before {
+ content: "\f136";
+}
+.icon-chat-badge::before {
+ content: "\f137";
+}
+.icon-chats-badge::before {
+ content: "\f138";
+}
+.icon-check::before {
+ content: "\f139";
+}
+.icon-check-bold::before {
+ content: "\f13a";
+}
+.icon-check-filled::before {
+ content: "\f13b";
+}
+.icon-choice-selected::before {
+ content: "\f13c";
+}
+.icon-clock::before {
+ content: "\f13d";
+}
+.icon-clock-edit::before {
+ content: "\f13e";
+}
+.icon-close::before {
+ content: "\f13f";
+}
+.icon-close-circle::before {
+ content: "\f140";
+}
+.icon-close-topic::before {
+ content: "\f141";
+}
+.icon-closed-gift::before {
+ content: "\f142";
+}
+.icon-cloud-download::before {
+ content: "\f143";
+}
+.icon-collapse::before {
+ content: "\f144";
+}
+.icon-collapse-modal::before {
+ content: "\f145";
+}
+.icon-colorize::before {
+ content: "\f146";
+}
+.icon-combine-craft::before {
+ content: "\f147";
+}
+.icon-comments::before {
+ content: "\f148";
+}
+.icon-comments-sticker::before {
+ content: "\f149";
+}
+.icon-copy::before {
+ content: "\f14a";
+}
+.icon-copy-media::before {
+ content: "\f14b";
+}
+.icon-craft::before {
+ content: "\f14c";
+}
+.icon-crop::before {
+ content: "\f14d";
+}
+.icon-crown-take-off::before {
+ content: "\f14e";
+}
+.icon-crown-take-off-outline::before {
+ content: "\f14f";
+}
+.icon-crown-wear::before {
+ content: "\f150";
+}
+.icon-crown-wear-outline::before {
+ content: "\f151";
+}
+.icon-darkmode::before {
+ content: "\f152";
+}
+.icon-data::before {
+ content: "\f153";
+}
+.icon-delete::before {
+ content: "\f154";
+}
+.icon-delete-filled::before {
+ content: "\f155";
+}
+.icon-delete-left::before {
+ content: "\f156";
+}
+.icon-delete-user::before {
+ content: "\f157";
+}
+.icon-diamond::before {
+ content: "\f158";
+}
+.icon-document::before {
+ content: "\f159";
+}
+.icon-double-badge::before {
+ content: "\f15a";
+}
+.icon-down::before {
+ content: "\f15b";
+}
+.icon-download::before {
+ content: "\f15c";
+}
+.icon-dropdown-arrows::before {
+ content: "\f15d";
+}
+.icon-eats::before {
+ content: "\f15e";
+}
+.icon-edit::before {
+ content: "\f15f";
+}
+.icon-email::before {
+ content: "\f160";
+}
+.icon-enter::before {
+ content: "\f161";
+}
+.icon-expand::before {
+ content: "\f162";
+}
+.icon-expand-modal::before {
+ content: "\f163";
+}
+.icon-eye::before {
+ content: "\f164";
+}
+.icon-eye-crossed::before {
+ content: "\f165";
+}
+.icon-eye-crossed-outline::before {
+ content: "\f166";
+}
+.icon-eye-outline::before {
+ content: "\f167";
+}
+.icon-favorite::before {
+ content: "\f168";
+}
+.icon-favorite-filled::before {
+ content: "\f169";
+}
+.icon-file-badge::before {
+ content: "\f16a";
+}
+.icon-flag::before {
+ content: "\f16b";
+}
+.icon-flip::before {
+ content: "\f16c";
+}
+.icon-folder::before {
+ content: "\f16d";
+}
+.icon-folder-badge::before {
+ content: "\f16e";
+}
+.icon-folder-tabs-bot::before {
+ content: "\f16f";
+}
+.icon-folder-tabs-channel::before {
+ content: "\f170";
+}
+.icon-folder-tabs-chat::before {
+ content: "\f171";
+}
+.icon-folder-tabs-chats::before {
+ content: "\f172";
+}
+.icon-folder-tabs-folder::before {
+ content: "\f173";
+}
+.icon-folder-tabs-group::before {
+ content: "\f174";
+}
+.icon-folder-tabs-star::before {
+ content: "\f175";
+}
+.icon-folder-tabs-user::before {
+ content: "\f176";
+}
+.icon-fontsize::before {
+ content: "\f177";
+}
+.icon-forums::before {
+ content: "\f178";
+}
+.icon-forward::before {
+ content: "\f179";
+}
+.icon-fragment::before {
+ content: "\f17a";
+}
+.icon-frozen-time::before {
+ content: "\f17b";
+}
+.icon-fullscreen::before {
+ content: "\f17c";
+}
+.icon-gifs::before {
+ content: "\f17d";
+}
+.icon-gift::before {
+ content: "\f17e";
+}
+.icon-gift-transfer-inline::before {
+ content: "\f17f";
+}
+.icon-group::before {
+ content: "\f180";
+}
+.icon-group-filled::before {
+ content: "\f181";
+}
+.icon-grouped::before {
+ content: "\f182";
+}
+.icon-grouped-disable::before {
+ content: "\f183";
+}
+.icon-hand-stop::before {
+ content: "\f184";
+}
+.icon-hand-stop-filled::before {
+ content: "\f185";
+}
+.icon-hashtag::before {
+ content: "\f186";
+}
+.icon-hd-photo::before {
+ content: "\f187";
+}
+.icon-heart::before {
+ content: "\f188";
+}
+.icon-heart-outline::before {
+ content: "\f189";
+}
+.icon-help::before {
+ content: "\f18a";
+}
+.icon-info::before {
+ content: "\f18b";
+}
+.icon-info-filled::before {
+ content: "\f18c";
+}
+.icon-install::before {
+ content: "\f18d";
+}
+.icon-italic::before {
+ content: "\f18e";
+}
+.icon-key::before {
+ content: "\f18f";
+}
+.icon-keyboard::before {
+ content: "\f190";
+}
+.icon-lamp::before {
+ content: "\f191";
+}
+.icon-language::before {
+ content: "\f192";
+}
+.icon-large-pause::before {
+ content: "\f193";
+}
+.icon-large-play::before {
+ content: "\f194";
+}
+.icon-link::before {
+ content: "\f195";
+}
+.icon-link-badge::before {
+ content: "\f196";
+}
+.icon-link-broken::before {
+ content: "\f197";
+}
+.icon-location::before {
+ content: "\f198";
+}
+.icon-lock::before {
+ content: "\f199";
+}
+.icon-lock-badge::before {
+ content: "\f19a";
+}
+.icon-logout::before {
+ content: "\f19b";
+}
+.icon-loop::before {
+ content: "\f19c";
+}
+.icon-mention::before {
+ content: "\f19d";
+}
+.icon-menu::before {
+ content: "\f19e";
+}
+.icon-message::before {
+ content: "\f19f";
+}
+.icon-message-failed::before {
+ content: "\f1a0";
+}
+.icon-message-pending::before {
+ content: "\f1a1";
+}
+.icon-message-read::before {
+ content: "\f1a2";
+}
+.icon-message-succeeded::before {
+ content: "\f1a3";
+}
+.icon-microphone::before {
+ content: "\f1a4";
+}
+.icon-microphone-alt::before {
+ content: "\f1a5";
+}
+.icon-monospace::before {
+ content: "\f1a6";
+}
+.icon-more::before {
+ content: "\f1a7";
+}
+.icon-more-circle::before {
+ content: "\f1a8";
+}
+.icon-move-caption-down::before {
+ content: "\f1a9";
+}
+.icon-move-caption-up::before {
+ content: "\f1aa";
+}
+.icon-mute::before {
+ content: "\f1ab";
+}
+.icon-muted::before {
+ content: "\f1ac";
+}
+.icon-my-notes::before {
+ content: "\f1ad";
+}
+.icon-new-chat-filled::before {
+ content: "\f1ae";
+}
+.icon-new-send::before {
+ content: "\f1af";
+}
+.icon-next::before {
+ content: "\f1b0";
+}
+.icon-next-link::before {
+ content: "\f1b1";
+}
+.icon-no-download::before {
+ content: "\f1b2";
+}
+.icon-no-share::before {
+ content: "\f1b3";
+}
+.icon-nochannel::before {
+ content: "\f1b4";
+}
+.icon-noise-suppression::before {
+ content: "\f1b5";
+}
+.icon-non-contacts::before {
+ content: "\f1b6";
+}
+.icon-note::before {
+ content: "\f1b7";
+}
+.icon-one-filled::before {
+ content: "\f1b8";
+}
+.icon-open-in-new-tab::before {
+ content: "\f1b9";
+}
+.icon-password-off::before {
+ content: "\f1ba";
+}
+.icon-pause::before {
+ content: "\f1bb";
+}
+.icon-permissions::before {
+ content: "\f1bc";
+}
+.icon-phone::before {
+ content: "\f1bd";
+}
+.icon-phone-discard::before {
+ content: "\f1be";
+}
+.icon-phone-discard-outline::before {
+ content: "\f1bf";
+}
+.icon-photo::before {
+ content: "\f1c0";
+}
+.icon-pin::before {
+ content: "\f1c1";
+}
+.icon-pin-badge::before {
+ content: "\f1c2";
+}
+.icon-pin-list::before {
+ content: "\f1c3";
+}
+.icon-pinned-chat::before {
+ content: "\f1c4";
+}
+.icon-pinned-message::before {
+ content: "\f1c5";
+}
+.icon-pip::before {
+ content: "\f1c6";
+}
+.icon-play::before {
+ content: "\f1c7";
+}
+.icon-play-story::before {
+ content: "\f1c8";
+}
+.icon-poll::before {
+ content: "\f1c9";
+}
+.icon-poll-badge::before {
+ content: "\f1ca";
+}
+.icon-previous::before {
+ content: "\f1cb";
+}
+.icon-previous-link::before {
+ content: "\f1cc";
+}
+.icon-privacy-policy::before {
+ content: "\f1cd";
+}
+.icon-proof-of-ownership::before {
+ content: "\f1ce";
+}
+.icon-quote::before {
+ content: "\f1cf";
+}
+.icon-quote-text::before {
+ content: "\f1d0";
+}
+.icon-radial-badge::before {
+ content: "\f1d1";
+}
+.icon-rating-icons-level1::before {
+ content: "\f1d2";
+}
+.icon-rating-icons-level10::before {
+ content: "\f1d3";
+}
+.icon-rating-icons-level2::before {
+ content: "\f1d4";
+}
+.icon-rating-icons-level20::before {
+ content: "\f1d5";
+}
+.icon-rating-icons-level3::before {
+ content: "\f1d6";
+}
+.icon-rating-icons-level30::before {
+ content: "\f1d7";
+}
+.icon-rating-icons-level4::before {
+ content: "\f1d8";
+}
+.icon-rating-icons-level40::before {
+ content: "\f1d9";
+}
+.icon-rating-icons-level5::before {
+ content: "\f1da";
+}
+.icon-rating-icons-level50::before {
+ content: "\f1db";
+}
+.icon-rating-icons-level6::before {
+ content: "\f1dc";
+}
+.icon-rating-icons-level60::before {
+ content: "\f1dd";
+}
+.icon-rating-icons-level7::before {
+ content: "\f1de";
+}
+.icon-rating-icons-level70::before {
+ content: "\f1df";
+}
+.icon-rating-icons-level8::before {
+ content: "\f1e0";
+}
+.icon-rating-icons-level80::before {
+ content: "\f1e1";
+}
+.icon-rating-icons-level9::before {
+ content: "\f1e2";
+}
+.icon-rating-icons-level90::before {
+ content: "\f1e3";
+}
+.icon-rating-icons-negative::before {
+ content: "\f1e4";
+}
+.icon-readchats::before {
+ content: "\f1e5";
+}
+.icon-recent::before {
+ content: "\f1e6";
+}
+.icon-redo::before {
+ content: "\f1e7";
+}
+.icon-refund::before {
+ content: "\f1e8";
+}
+.icon-reload::before {
+ content: "\f1e9";
+}
+.icon-remove::before {
+ content: "\f1ea";
+}
+.icon-remove-quote::before {
+ content: "\f1eb";
+}
+.icon-reopen-topic::before {
+ content: "\f1ec";
+}
+.icon-reorder-tabs::before {
+ content: "\f1ed";
+}
+.icon-replace::before {
+ content: "\f1ee";
+}
+.icon-replace-round::before {
+ content: "\f1ef";
+}
+.icon-replies::before {
+ content: "\f1f0";
+}
+.icon-reply::before {
+ content: "\f1f1";
+}
+.icon-reply-filled::before {
+ content: "\f1f2";
+}
+.icon-revenue-split::before {
+ content: "\f1f3";
+}
+.icon-revote::before {
+ content: "\f1f4";
+}
+.icon-rotate::before {
+ content: "\f1f5";
+}
+.icon-save-story::before {
+ content: "\f1f6";
+}
+.icon-saved-messages::before {
+ content: "\f1f7";
+}
+.icon-schedule::before {
+ content: "\f1f8";
+}
+.icon-scheduled::before {
+ content: "\f1f9";
+}
+.icon-sd-photo::before {
+ content: "\f1fa";
+}
+.icon-search::before {
+ content: "\f1fb";
+}
+.icon-select::before {
+ content: "\f1fc";
+}
+.icon-select-filled::before {
+ content: "\f1fd";
+}
+.icon-sell::before {
+ content: "\f1fe";
+}
+.icon-sell-outline::before {
+ content: "\f1ff";
+}
+.icon-send::before {
+ content: "\f200";
+}
+.icon-send-outline::before {
+ content: "\f201";
+}
+.icon-settings::before {
+ content: "\f202";
+}
+.icon-settings-filled::before {
+ content: "\f203";
+}
+.icon-share-filled::before {
+ content: "\f204";
+}
+.icon-share-screen::before {
+ content: "\f205";
+}
+.icon-share-screen-outlined::before {
+ content: "\f206";
+}
+.icon-share-screen-stop::before {
+ content: "\f207";
+}
+.icon-show-message::before {
+ content: "\f208";
+}
+.icon-sidebar::before {
+ content: "\f209";
+}
+.icon-skip-next::before {
+ content: "\f20a";
+}
+.icon-skip-previous::before {
+ content: "\f20b";
+}
+.icon-smallscreen::before {
+ content: "\f20c";
+}
+.icon-smile::before {
+ content: "\f20d";
+}
+.icon-sort::before {
+ content: "\f20e";
+}
+.icon-sort-by-date::before {
+ content: "\f20f";
+}
+.icon-sort-by-number::before {
+ content: "\f210";
+}
+.icon-sort-by-price::before {
+ content: "\f211";
+}
+.icon-speaker::before {
+ content: "\f212";
+}
+.icon-speaker-muted-story::before {
+ content: "\f213";
+}
+.icon-speaker-outline::before {
+ content: "\f214";
+}
+.icon-speaker-story::before {
+ content: "\f215";
+}
+.icon-spoiler::before {
+ content: "\f216";
+}
+.icon-spoiler-disable::before {
+ content: "\f217";
+}
+.icon-sport::before {
+ content: "\f218";
+}
+.icon-star::before {
+ content: "\f219";
+}
+.icon-stars-lock::before {
+ content: "\f21a";
+}
+.icon-stars-refund::before {
+ content: "\f21b";
+}
+.icon-stats::before {
+ content: "\f21c";
+}
+.icon-stealth-future::before {
+ content: "\f21d";
+}
+.icon-stealth-past::before {
+ content: "\f21e";
+}
+.icon-stickers::before {
+ content: "\f21f";
+}
+.icon-stop::before {
+ content: "\f220";
+}
+.icon-stop-raising-hand::before {
+ content: "\f221";
+}
+.icon-story-caption::before {
+ content: "\f222";
+}
+.icon-story-expired::before {
+ content: "\f223";
+}
+.icon-story-priority::before {
+ content: "\f224";
+}
+.icon-story-reply::before {
+ content: "\f225";
+}
+.icon-strikethrough::before {
+ content: "\f226";
+}
+.icon-tag::before {
+ content: "\f227";
+}
+.icon-tag-add::before {
+ content: "\f228";
+}
+.icon-tag-crossed::before {
+ content: "\f229";
+}
+.icon-tag-filter::before {
+ content: "\f22a";
+}
+.icon-tag-name::before {
+ content: "\f22b";
+}
+.icon-timer::before {
+ content: "\f22c";
+}
+.icon-timer-filled::before {
+ content: "\f22d";
+}
+.icon-toncoin::before {
+ content: "\f22e";
+}
+.icon-tone::before {
+ content: "\f22f";
+}
+.icon-tools::before {
+ content: "\f230";
+}
+.icon-topic-new::before {
+ content: "\f231";
+}
+.icon-trade::before {
+ content: "\f232";
+}
+.icon-transcribe::before {
+ content: "\f233";
+}
+.icon-truck::before {
+ content: "\f234";
+}
+.icon-unarchive::before {
+ content: "\f235";
+}
+.icon-underlined::before {
+ content: "\f236";
+}
+.icon-understood::before {
+ content: "\f237";
+}
+.icon-undo::before {
+ content: "\f238";
+}
+.icon-unique-profile::before {
+ content: "\f239";
+}
+.icon-unlist::before {
+ content: "\f23a";
+}
+.icon-unlist-outline::before {
+ content: "\f23b";
+}
+.icon-unlock::before {
+ content: "\f23c";
+}
+.icon-unlock-badge::before {
+ content: "\f23d";
+}
+.icon-unmute::before {
+ content: "\f23e";
+}
+.icon-unpin::before {
+ content: "\f23f";
+}
+.icon-unread::before {
+ content: "\f240";
+}
+.icon-up::before {
+ content: "\f241";
+}
+.icon-user::before {
+ content: "\f242";
+}
+.icon-user-filled::before {
+ content: "\f243";
+}
+.icon-user-online::before {
+ content: "\f244";
+}
+.icon-user-stars::before {
+ content: "\f245";
+}
+.icon-user-tag::before {
+ content: "\f246";
+}
+.icon-video::before {
+ content: "\f247";
+}
+.icon-video-outlined::before {
+ content: "\f248";
+}
+.icon-video-stop::before {
+ content: "\f249";
+}
+.icon-view-once::before {
+ content: "\f24a";
+}
+.icon-voice-chat::before {
+ content: "\f24b";
+}
+.icon-volume-1::before {
+ content: "\f24c";
+}
+.icon-volume-2::before {
+ content: "\f24d";
+}
+.icon-volume-3::before {
+ content: "\f24e";
+}
+.icon-warning::before {
+ content: "\f24f";
+}
+.icon-web::before {
+ content: "\f250";
+}
+.icon-webapp::before {
+ content: "\f251";
+}
+.icon-word-wrap::before {
+ content: "\f252";
+}
+.icon-zoom-in::before {
+ content: "\f253";
+}
+.icon-zoom-out::before {
+ content: "\f254";
+}
diff --git a/frontend/src/lib/styles/icons.woff b/frontend/src/lib/styles/icons.woff
new file mode 100644
index 0000000..ed80cb0
Binary files /dev/null and b/frontend/src/lib/styles/icons.woff differ
diff --git a/frontend/src/lib/styles/icons.woff2 b/frontend/src/lib/styles/icons.woff2
new file mode 100644
index 0000000..38ad896
Binary files /dev/null and b/frontend/src/lib/styles/icons.woff2 differ
diff --git a/frontend/src/lib/styles/mixins.scss b/frontend/src/lib/styles/mixins.scss
new file mode 100644
index 0000000..b9c18d3
--- /dev/null
+++ b/frontend/src/lib/styles/mixins.scss
@@ -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);
+ }
+ }
+}
diff --git a/frontend/src/lib/styles/reboot.css b/frontend/src/lib/styles/reboot.css
new file mode 100644
index 0000000..8d7b905
--- /dev/null
+++ b/frontend/src/lib/styles/reboot.css
@@ -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;
+ }
+}
diff --git a/frontend/src/lib/styles/spacing.scss b/frontend/src/lib/styles/spacing.scss
new file mode 100644
index 0000000..70267c8
--- /dev/null
+++ b/frontend/src/lib/styles/spacing.scss
@@ -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;
+ }
+ }
+}
diff --git a/frontend/src/lib/styles/tokens.scss b/frontend/src/lib/styles/tokens.scss
new file mode 100644
index 0000000..1c0b153
--- /dev/null
+++ b/frontend/src/lib/styles/tokens.scss
@@ -0,0 +1 @@
+@forward "mixins";
diff --git a/frontend/src/lib/styles/variables.scss b/frontend/src/lib/styles/variables.scss
new file mode 100644
index 0000000..3f08b03
--- /dev/null
+++ b/frontend/src/lib/styles/variables.scss
@@ -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;
+ }
+ }
+}
diff --git a/frontend/src/lib/transitions/appear.ts b/frontend/src/lib/transitions/appear.ts
new file mode 100644
index 0000000..13deb69
--- /dev/null
+++ b/frontend/src/lib/transitions/appear.ts
@@ -0,0 +1,19 @@
+import { cubicOut } from "svelte/easing";
+
+interface AppearParams {
+ disabled?: boolean;
+}
+
+export function appear(
+ _node: HTMLElement,
+ { disabled = false }: AppearParams = {}
+) {
+ if (disabled) {
+ return { duration: 0 };
+ }
+ return {
+ duration: 200,
+ easing: cubicOut,
+ css: (t: number) => `opacity: ${t}; transform: scale(${0.85 + t * 0.15});`,
+ };
+}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 32b6a02..c2b1bb6 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,9 +1,47 @@
-
{@render children()}
+
diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts
new file mode 100644
index 0000000..83addb7
--- /dev/null
+++ b/frontend/src/routes/+layout.ts
@@ -0,0 +1,2 @@
+export const ssr = false;
+export const prerender = false;
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 3d70440..3b3c94f 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -1,5 +1,8 @@
-Welcome to SvelteKit
-
- Visit svelte.dev/docs/kit to read
- the documentation
-
+
diff --git a/frontend/src/routes/app/+layout.svelte b/frontend/src/routes/app/+layout.svelte
new file mode 100644
index 0000000..4e2add0
--- /dev/null
+++ b/frontend/src/routes/app/+layout.svelte
@@ -0,0 +1,86 @@
+
+
+
+
+
+ {@render children()}
+
+
+
+
diff --git a/frontend/src/routes/app/+page.svelte b/frontend/src/routes/app/+page.svelte
new file mode 100644
index 0000000..7f00d33
--- /dev/null
+++ b/frontend/src/routes/app/+page.svelte
@@ -0,0 +1,23 @@
+
+ Select a chat to view its archive
+
+
+
diff --git a/frontend/src/routes/app/[chatId]/+page.svelte b/frontend/src/routes/app/[chatId]/+page.svelte
new file mode 100644
index 0000000..8d48058
--- /dev/null
+++ b/frontend/src/routes/app/[chatId]/+page.svelte
@@ -0,0 +1,38 @@
+
+
+
+ {#key chatId}
+
+
+
+
+ {/key}
+
+
+
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
new file mode 100644
index 0000000..295f8ed
--- /dev/null
+++ b/frontend/src/routes/login/+page.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
index 9c99683..ab32a4d 100644
--- a/frontend/svelte.config.js
+++ b/frontend/svelte.config.js
@@ -9,7 +9,7 @@ const config = {
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
},
kit: {
- adapter: adapter(),
+ adapter: adapter({ fallback: "index.html" }),
},
};
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4b97347..eb7b75f 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -2,4 +2,21 @@ import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
-export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
+const proxyTarget = process.env.API_PROXY_TARGET ?? "http://127.0.0.1:8080";
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ css: {
+ preprocessorOptions: {
+ scss: {
+ loadPaths: ["src/lib/styles"],
+ },
+ },
+ },
+ server: {
+ proxy: {
+ "/api": { target: proxyTarget, changeOrigin: true },
+ "/mcp": { target: proxyTarget, changeOrigin: true },
+ },
+ },
+});