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 @@ + + + + + + + {#each accounts.list as account (account.account_id)} + accounts.select(account.account_id)} + > + + {accountName(account)} + {#if account.account_id === accounts.selectedId} + + {/if} + + {/each} + + + + + 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 @@ + + +
+ +
+

{title}

+ {subtitle} +
+
+ + 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 @@ + + +
+ {#if chats.loading && chats.list.length === 0} + {#each skeletonRows as index (index)} +
+ +
+ + +
+
+ {/each} + {:else if chats.list.length === 0} + + {:else} + {#key folders.selectedId} +
+ {#if visibleChats.length === 0} + + {:else} + {#each visibleChats as chat (chat.chat_id)} + goto(`/app/${chat.chat_id}`)} + /> + {/each} + {/if} +
+ {/key} + {/if} +
+ + 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} + + +
+ + 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 @@ + + +
+ Forwarded from + {name} + {#if forward.signature} + ({forward.signature}) + {/if} +
+ + 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 @@ + + +
+ {#each media as item, index (item.id ?? index)} + onopen(index)} /> + {/each} +
+ + 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"} + + {version.kind} + + {: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} + {kind} + {: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"} +
+

This media has not been downloaded yet.

+ +
+ {: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 @@ + + +
+ {#if loading && messages.length === 0} +
+ {#each skeletonBubbles as width, index (index)} +
+ {/each} +
+ {:else if rows.length === 0} + + {:else} +
+ {#if loadingOlder} +
+ {/if} + {#each rows as row (row.message.message_id)} + {#if row.daySeparator} +
+ {row.daySeparator} +
+ {/if} + openMedia(row.message, index)} + onversions={() => openVersions(row.message.message_id)} + /> + {/each} +
+ {/if} +
+ + + + + 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 @@ + + +
+ {#if message.is_self_destruct} + + {:else if !loaded} +
+ {:else if ready && kind === "voice"} + + {:else if ready && kind === "video_note"} + + {:else if ready && kind === "audio"} + + {:else if ready && isImage} + + {:else if ready && isStaticSticker} + + {:else if ready && isVideoSticker} + + {:else if ready && isTgsSticker} +
+ {message.sticker?.emoji ?? "🎞"} +
+ {:else if ready && isAnimation} + + {:else if ready && isThumbVideo} + + {:else if ready} + + {:else if media?.state === "not-downloaded" && vk !== "other"} + + {:else if media?.state === "not-downloaded"} + + {:else} + + {/if} +
+ + 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 @@ + + + + + + +
+ Edit history + + + +
+
+ {#if loading} +
+ {:else if versions.length === 0 && mediaVersions.length === 0} +

No versions recorded.

+ {:else} + {#if mediaVersions.length > 0} +
+ Media versions +
+ {#each mediaVersions as media (media.id)} + + {/each} +
+
+ {/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} + {name} + {: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 }, + }, + }, +});