From 75425d1beeeef965acb7172a78cd4af27bfae047 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 31 May 2026 01:27:40 +0200 Subject: [PATCH] feat: 1-to-1 message render + web data-lake backend --- .../versions/a9c3e7f1d2b4_media_unique_id.py | 27 + .../versions/f7a2c9e1b3d5_media_versions.py | 56 + backend/src/api/app.py | 18 + backend/src/api/routers/accounts.py | 13 + backend/src/api/routers/avatars.py | 40 + backend/src/api/routers/chats.py | 15 + backend/src/api/routers/media.py | 51 +- backend/src/api/routers/peers.py | 8 + backend/src/userbot/handlers/edits.py | 7 +- .../src/userbot/modules/avatars/__init__.py | 3 +- .../src/userbot/modules/avatars/repository.py | 63 + .../src/userbot/modules/capture/context.py | 3 + .../src/userbot/modules/capture/identity.py | 110 ++ .../src/userbot/modules/capture/message.py | 3 + .../src/userbot/modules/capture/repository.py | 109 +- .../src/userbot/modules/groups/repository.py | 20 + .../userbot/modules/jobs/handlers/__init__.py | 10 +- .../modules/jobs/handlers/enrich_chat.py | 91 ++ .../modules/jobs/handlers/fetch_avatar.py | 40 + backend/src/userbot/modules/media/__init__.py | 8 +- .../src/userbot/modules/media/downloader.py | 30 +- .../src/userbot/modules/profiles/__init__.py | 8 +- backend/src/userbot/modules/profiles/parse.py | 17 + backend/src/utils/env.py | 1 + backend/src/utils/read/accounts.py | 11 + backend/src/utils/read/avatars.py | 30 + backend/src/utils/read/chats.py | 112 +- backend/src/utils/read/media.py | 28 +- backend/src/utils/read/message_view.py | 404 +++++++ backend/src/utils/read/models.py | 162 +++ backend/src/utils/read/peers.py | 24 +- docker-compose.override.yml.example | 4 + docker-compose.yml | 14 + frontend/biome.jsonc | 12 + frontend/bun.lock | 82 +- frontend/package.json | 7 +- frontend/src/app.html | 10 + frontend/src/lib/actions/ripple.ts | 32 + frontend/src/lib/actions/visible.ts | 20 + frontend/src/lib/api/avatars.ts | 74 ++ frontend/src/lib/api/client.ts | 141 +++ frontend/src/lib/api/endpoints.ts | 120 ++ frontend/src/lib/api/media.ts | 175 +++ frontend/src/lib/api/types.ts | 375 ++++++ .../src/lib/components/AccountSwitcher.svelte | 87 ++ frontend/src/lib/components/ChatHeader.svelte | 104 ++ frontend/src/lib/components/ChatList.svelte | 95 ++ .../src/lib/components/ChatListItem.svelte | 163 +++ frontend/src/lib/components/EntityText.svelte | 179 +++ frontend/src/lib/components/FolderTabs.svelte | 171 +++ .../src/lib/components/ForwardHeader.svelte | 60 + .../src/lib/components/LoginScreen.svelte | 124 ++ frontend/src/lib/components/MediaAlbum.svelte | 42 + .../lib/components/MediaVersionThumb.svelte | 117 ++ .../src/lib/components/MediaViewer.svelte | 317 +++++ .../src/lib/components/MessageBubble.svelte | 262 +++++ .../src/lib/components/MessageList.svelte | 353 ++++++ .../src/lib/components/MessageMedia.svelte | 321 +++++ .../src/lib/components/MessageMeta.svelte | 55 + .../src/lib/components/MessageVersions.svelte | 212 ++++ .../src/lib/components/ReplyHeader.svelte | 110 ++ .../src/lib/components/media/AlbumTile.svelte | 106 ++ .../src/lib/components/media/AudioFile.svelte | 153 +++ .../src/lib/components/media/VideoNote.svelte | 203 ++++ .../lib/components/media/VoiceMessage.svelte | 227 ++++ frontend/src/lib/components/ui/Avatar.svelte | 104 ++ frontend/src/lib/components/ui/Button.svelte | 211 ++++ .../src/lib/components/ui/EmptyState.svelte | 41 + frontend/src/lib/components/ui/Icon.svelte | 15 + .../src/lib/components/ui/ListItem.svelte | 58 + .../src/lib/components/ui/Skeleton.svelte | 22 + frontend/src/lib/components/ui/Spinner.svelte | 39 + .../src/lib/components/ui/ToastHost.svelte | 57 + frontend/src/lib/format/datetime.ts | 39 + frontend/src/lib/format/duration.ts | 9 + frontend/src/lib/format/entities.ts | 105 ++ frontend/src/lib/format/folders.ts | 33 + frontend/src/lib/format/media.ts | 25 + frontend/src/lib/format/peer.ts | 48 + frontend/src/lib/media/playback.ts | 14 + frontend/src/lib/media/waveform.ts | 36 + frontend/src/lib/stores/accounts.svelte.ts | 66 ++ frontend/src/lib/stores/auth.svelte.ts | 37 + frontend/src/lib/stores/chats.svelte.ts | 93 ++ frontend/src/lib/stores/folders.svelte.ts | 74 ++ frontend/src/lib/stores/peers.svelte.ts | 70 ++ frontend/src/lib/stores/theme.svelte.ts | 67 ++ frontend/src/lib/stores/toasts.svelte.ts | 47 + frontend/src/lib/stores/ui.svelte.ts | 39 + frontend/src/lib/styles/dark-theme.scss | 73 ++ frontend/src/lib/styles/forms.scss | 270 +++++ frontend/src/lib/styles/global.scss | 202 ++++ frontend/src/lib/styles/icons.css | 1044 +++++++++++++++++ frontend/src/lib/styles/icons.woff | Bin 0 -> 54792 bytes frontend/src/lib/styles/icons.woff2 | Bin 0 -> 46608 bytes frontend/src/lib/styles/mixins.scss | 271 +++++ frontend/src/lib/styles/reboot.css | 334 ++++++ frontend/src/lib/styles/spacing.scss | 52 + frontend/src/lib/styles/tokens.scss | 1 + frontend/src/lib/styles/variables.scss | 359 ++++++ frontend/src/lib/transitions/appear.ts | 19 + frontend/src/routes/+layout.svelte | 42 +- frontend/src/routes/+layout.ts | 2 + frontend/src/routes/+page.svelte | 13 +- frontend/src/routes/app/+layout.svelte | 86 ++ frontend/src/routes/app/+page.svelte | 23 + frontend/src/routes/app/[chatId]/+page.svelte | 38 + frontend/src/routes/login/+page.svelte | 5 + frontend/svelte.config.js | 2 +- frontend/vite.config.ts | 19 +- 110 files changed, 10199 insertions(+), 54 deletions(-) create mode 100644 backend/migrations/versions/a9c3e7f1d2b4_media_unique_id.py create mode 100644 backend/migrations/versions/f7a2c9e1b3d5_media_versions.py create mode 100644 backend/src/api/routers/accounts.py create mode 100644 backend/src/api/routers/avatars.py create mode 100644 backend/src/userbot/modules/capture/identity.py create mode 100644 backend/src/userbot/modules/jobs/handlers/enrich_chat.py create mode 100644 backend/src/userbot/modules/jobs/handlers/fetch_avatar.py create mode 100644 backend/src/utils/read/accounts.py create mode 100644 backend/src/utils/read/avatars.py create mode 100644 backend/src/utils/read/message_view.py create mode 100644 frontend/src/lib/actions/ripple.ts create mode 100644 frontend/src/lib/actions/visible.ts create mode 100644 frontend/src/lib/api/avatars.ts create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/api/endpoints.ts create mode 100644 frontend/src/lib/api/media.ts create mode 100644 frontend/src/lib/api/types.ts create mode 100644 frontend/src/lib/components/AccountSwitcher.svelte create mode 100644 frontend/src/lib/components/ChatHeader.svelte create mode 100644 frontend/src/lib/components/ChatList.svelte create mode 100644 frontend/src/lib/components/ChatListItem.svelte create mode 100644 frontend/src/lib/components/EntityText.svelte create mode 100644 frontend/src/lib/components/FolderTabs.svelte create mode 100644 frontend/src/lib/components/ForwardHeader.svelte create mode 100644 frontend/src/lib/components/LoginScreen.svelte create mode 100644 frontend/src/lib/components/MediaAlbum.svelte create mode 100644 frontend/src/lib/components/MediaVersionThumb.svelte create mode 100644 frontend/src/lib/components/MediaViewer.svelte create mode 100644 frontend/src/lib/components/MessageBubble.svelte create mode 100644 frontend/src/lib/components/MessageList.svelte create mode 100644 frontend/src/lib/components/MessageMedia.svelte create mode 100644 frontend/src/lib/components/MessageMeta.svelte create mode 100644 frontend/src/lib/components/MessageVersions.svelte create mode 100644 frontend/src/lib/components/ReplyHeader.svelte create mode 100644 frontend/src/lib/components/media/AlbumTile.svelte create mode 100644 frontend/src/lib/components/media/AudioFile.svelte create mode 100644 frontend/src/lib/components/media/VideoNote.svelte create mode 100644 frontend/src/lib/components/media/VoiceMessage.svelte create mode 100644 frontend/src/lib/components/ui/Avatar.svelte create mode 100644 frontend/src/lib/components/ui/Button.svelte create mode 100644 frontend/src/lib/components/ui/EmptyState.svelte create mode 100644 frontend/src/lib/components/ui/Icon.svelte create mode 100644 frontend/src/lib/components/ui/ListItem.svelte create mode 100644 frontend/src/lib/components/ui/Skeleton.svelte create mode 100644 frontend/src/lib/components/ui/Spinner.svelte create mode 100644 frontend/src/lib/components/ui/ToastHost.svelte create mode 100644 frontend/src/lib/format/datetime.ts create mode 100644 frontend/src/lib/format/duration.ts create mode 100644 frontend/src/lib/format/entities.ts create mode 100644 frontend/src/lib/format/folders.ts create mode 100644 frontend/src/lib/format/media.ts create mode 100644 frontend/src/lib/format/peer.ts create mode 100644 frontend/src/lib/media/playback.ts create mode 100644 frontend/src/lib/media/waveform.ts create mode 100644 frontend/src/lib/stores/accounts.svelte.ts create mode 100644 frontend/src/lib/stores/auth.svelte.ts create mode 100644 frontend/src/lib/stores/chats.svelte.ts create mode 100644 frontend/src/lib/stores/folders.svelte.ts create mode 100644 frontend/src/lib/stores/peers.svelte.ts create mode 100644 frontend/src/lib/stores/theme.svelte.ts create mode 100644 frontend/src/lib/stores/toasts.svelte.ts create mode 100644 frontend/src/lib/stores/ui.svelte.ts create mode 100644 frontend/src/lib/styles/dark-theme.scss create mode 100644 frontend/src/lib/styles/forms.scss create mode 100644 frontend/src/lib/styles/global.scss create mode 100644 frontend/src/lib/styles/icons.css create mode 100644 frontend/src/lib/styles/icons.woff create mode 100644 frontend/src/lib/styles/icons.woff2 create mode 100644 frontend/src/lib/styles/mixins.scss create mode 100644 frontend/src/lib/styles/reboot.css create mode 100644 frontend/src/lib/styles/spacing.scss create mode 100644 frontend/src/lib/styles/tokens.scss create mode 100644 frontend/src/lib/styles/variables.scss create mode 100644 frontend/src/lib/transitions/appear.ts create mode 100644 frontend/src/routes/+layout.ts create mode 100644 frontend/src/routes/app/+layout.svelte create mode 100644 frontend/src/routes/app/+page.svelte create mode 100644 frontend/src/routes/app/[chatId]/+page.svelte create mode 100644 frontend/src/routes/login/+page.svelte 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 0000000000000000000000000000000000000000..ed80cb06c697c0a6eb578a10816e4e18a8bd5b5d GIT binary patch literal 54792 zcmY&;V{m0n*LE^+5an~<9;xCXuIY8pu8`+tCafx5`5PsnXbG`4$(ZS`*W}*QC z0$v3I0?mnt#tQu>^yA-&BznLi0l5A-FgYioRl;F9)UQFX9DVA7{yKP+t5` z1X@%oT6hzc!`oGK5e8{4stSKQc@+QVChmoj13aAFTCm%^AEm9bNl|LuZhA2)5|^YP zPnO-bgG1kl)NF3?-MskI7tR;e6qbGD8Xu(3gOOu}9d3sg-5dy%le*i|zdtM3iK^fq zZ?8rkHRMv*l>o6Stt52T=KZ5x^9Zs|g*Dpd1GDe)xZY1h3|`p&`Xd=Li`;ArW*SAB ze#^(5#2sQuzLqs4RY@H%En>~GnlS#(mNxnGh$ZEj-VhB@tIs&OE)t900*g*-5}y;8 z9JP`vw;DOQ8o;m`Ubz}&{>Av4M`+(%E`TU`DVhQzH!^sZ*~{NX*1j~*L%TD}Uj$~X;Y<|bs_J^GI$0!#Ej2A~l7l#5D z$Nv!>KoT9X5bY`v9m8iISY_`K^Ysh_Pd5+xx6`%c;E;?_CJ(;i85Spxnah~Va5Y{yN-+SYm zBkOy|&fgvAB@79lh(2}E`a1)^|lBF{08FB^QtLQAe5Er7DiM{;Wb zMzbu`CRgu$sr$%o0YG{71*zof*MiaDCYs=;(qNv+n|FWJT)W^yukThxK)Yn1T@!GM zWuZ0slJ(2y7TL`o$k^b`MT%vqWBZIEMmy8ApqPROavt|!{mPV-d};q>R*viz1zghZ zLX78RZLqVoTpyTeDh1k`mt+utu!GwQf%ax4Is_oj;I>Mjy+z5oP8V4MLc_V?Go#$v ztfB|&-E$VQUAtyi#{XFBl6E}MMQ~fl;j5sPg2=jla8oC;-7D_e14qNTh2{G8jQd~U z`EQYRpM8pGc11iXw5XcQhrxwWZg@%tH%Mz^Ft4AEc5zz4Mh}T2mn9t7ZH) z&lU2tGY^?S0f!PMO@a9@uG6xF2@fm#K!iZ zwSM7qtGMa6B+`caCdF6H5+8;IUuVS^+Q-4cU%)`SU7fYFVL!&)W#3=8nsAW=lO_#mED(wx_?j2YH()6ZX_<(d^O zG)veR7PPQIwh`Xh)f2TdoXf)Ne28|p3=Ir5UoST%jA$1@-@wqMn4(!C!?0k64RS_w z3kqtR6V@{WRy8RuYnC)(H-18stoJ!Ue63>*o$#5aVm`k<%0QbMkKpCYlh^c%?AMd* zi|p0atkpFBH2&2H6C)2JCn0Q2?r*5raS%}As-`ST%z0>-vh1G!-trC6O-O|xlS0Fv z|B#1DM2fS2z~8?4m2ZKwwKOqqr;oYTU)##`I{|LqV6Ww7?!I2)KZ%(;Q+xJD0=-TT zKWJt0Oi5eF3F%H-^ojo1&u0nA7t&Miw$El)AOqDCd^MyGU#QELwU-;2 zfd%W9)j|pPQtsXQ{9JFalWuFfg4*raALWvh^3e7idex`cKkVeCr7?_= zl7N0m+MUV-A{?H%Y^!OOpVGgzCOO5ItiJu1S4qHP%l~@KJ^ol(d}0<&5!j8V(Gee7 zt9i4)au>(4NC!Vq{S>;4EgtnRyAut|RZu^46!$;+VFV`wCd+F2bn);^73aDhGBka- zYRttHtXPZ&vYE0q5atlRc@G^%`0Wn8vR(7K{`ZSL^B0E9g9NcrK3pu z`L!8EN4h>~dWT+K;xTYnQVdN4fsxV&P`KHWxX4E_abwsSfhqLhfS#&`;{#V~hn=w> z*@qc?{_x%3;SCEye%c8It@s>FkEY+k4LktE6wq@i3IPE21^am9(=R$8v2 ze#Nhiaag&q-d^}KWYsg9YT8AB3!aRI%~FoF#fF(0$!MzTmsBm1qB2k?x9h#(c zH2AGA&Se^4Q>kCwd0*i|Y#_h&l(MS*z?6_CTbDsH-Q6Lds{8UFwWtZzhIN0Ao_Z{| zVRWJgH(jXGD=_iFde#i)xHE9oJV}-^Zq?7o6sf0<`5CcEW}pyN5K`YBFsrvJ9joq>i2D)5CI=~)~qv%3x%RA3+3=U^Lze063V+Bn^D<1UapwF zjRB+0BO`l!Bqiowd0wr0LA#zP?c`#meT;|Nor8=C=kgegi{eBSa;ijP3H5M#w@L4V z*V4HfLlLjnX2Ki&UOU|wyyIc;PQ;hNpYm_AR4vw~>mwsg^`|iW?hQ*PiByzhS>op> z5!EFGG1|`~BTMf(5-UObo=cAyf2rIUVR%nu+v@7!UW|=Tq>F9yOo=$mXAh`_&&>lF znFw$VWi^kqG-bZSVyfRtf*8O*0E6-Q3i)SGccS0ThKq*kaX211GG3$4Y}g-Qn9jZS z&5E1pwD}3%!Y{ZAP;7CjqwM)UUp%_jRQJSsh}*5W#n_af{K(cv%n4LX(k52elz1f` zI8w3+tBXP6<;2Wr4J>+g$>JKLg&pblmHdiL85bPkMGu++AuB7>Bo+`4J2R%0^gQb9 z%n`=V$!Z8vkW>8;fquo)q!{tkLsu8mqmoZPH=A%wdJ0_=_bv<0NLP+n5^NDjfbJ$F`a3=Jpr)0er(FJbs(D7o5)`i`QFC$BrcAe~(<6-~K zj+yQ{JkLiHvuAwd2H-FTi5zJ!CSE#r$!U}RnH()mdzxAFs6!~3DBS|BL8I1vZs0o= zJYqlk0M(Y*Wyro|PFmJD%-$()7nQPu1;k2q;6WTtWEtza zTzdRpo@3=v3!7oc7+&J}49s;&7^{Fz|Bazm-82G!!j2R#tO{KE-&%=3^#1Y{B_Nfn ziWrNb+c4x#kANlb<=Y|Y;u625Zki*SH>vsKRsCX~kFLqIb$7_8{85Y@n}TpgTD%)? zcRKKN&_bxYV~BR@+Y4~p67wiV4JJ+t9WPA#9B~6q47LxT=UKvnLhBlkLT&?p0gBC~Mp$(VOHP;M0RNo_< zi5%1(r`iF(=_mUF*KO<)nyj11ydTwX)O&FIH}Ip&muQG^2hA|3ZgF!fp}&>$YZaCt zaozkH{fh&aq8cKX{0}~#nHNEJ@SgaD81%FCl#r{lY+k^5{;07!t3_j+A|)Cb3H6c( za5%e3Mh2~-gM-ujadb|QtIMCO(SSNGniJ>uV*L5vfd|Q^W1#ckoPOO;FI*t0X z6LeCw3*3}2@Vf3%ooh-sIZ|*?%*q5*`A=3`wtfQxTDD$C+^2TSHUPO6UM;tVoknrY zdgL7)!O9J?@(s3XZIwGJr80-OK}e)WXh=oy8363Xa>8mAY%f0J$}ZNxEr=7dk=tPD zFjJr!eG@`;j0fmGdNCML`?n+nNr)h653CF^w(2wvZ+L9xzSh8FJ3LH%{-_ZRNcbYQ2E1}_kX zKei)58j?*j_Up>cF$Apr5q(5|iL@f$7+UJ>HC`%FL)Vl^s@_=3c7Fv?hEpZ43=%8%drj&_JF=`Tx)^IHNS`2rkD znO%3eWSBpWM|klVY#lT3u;JjHL%HFhuVQhNZCq&$qa$tda12c9%hX)1GEg|P{vb(Nn=>&%~=gk&`M7eV2Bw=m2 z>Y6zs0fD~!zLmSQ@oU-~i|nBqEKByf6|>%a9RldM+|A%n(@l|=yt)o@kj&vtwGAU6GiU< z*IBf5)sRMdCDl|7c*`DQ&A=%KlUlD0+howsV3=!HpBO<6kvoZ&+K-Ek$4$>h&noT$ zLp$7ikZy^z!)(1#=`Mk zold>9&i$jmfupJFq4`A`150k9av`>8Jz0@1aWheeFpaSEiKf}M$po-DTvw{wkKd9$ zz-Y?A$*beh;WxZAMLn)(e4|lA)S<*;R$=mU*|=9w6-G=I!k@&H%s4gN-~n+`ExWl) z>u-TE{x>b-4RnFbD%Er!pI+s%>T0dh-2Zlb1f31nPc34QFa(rFt^-sVyaq9a*^Uc# zFEjC)4@^|S$4*75qHk9tYmMd8Iq@8BYP#51O0g_)vJSr&4?4X$GE_LEu*J0I5;IxS zBriL~UCKF93S^`Xun{VD!!M3=&r# zLEpcJ;qsMhcxQpfu&4}?Xky#cm8QIrl*o-qbNNYHj%X0l1{q{RYoio13V7i)k~ur@LLlOmS4dy0bFXwo5*-S@MjPUIZRG>D4ccaKn_ct0Zvb``qx(%zaKmQ z(`V8;kyBS9yt%@Ndik)0)NnpVVGrQsODED%5srBe6|6G42jrib++PXxGv&ftX)_zI zuohFqj~7+;`YaHz@`6Z{lVpBwm#;}6o7eF4K!jD@mYLuoqp z#K2#A1|ptvdqr|APPYR))n-q5cs7K#EsmSMDfpzYIb_MmplqT&JO{g4Zo*4mTgqZm0cA zQ}$^cQ`1X7Zks}qBokqe@Voo?5N9k2T=I`yf4iPgs&7iaKDxP5I4M>zBlgyCxAR&! zQFCERH2${QQe?qfc=w%>Q&^xl+8=&Cn&NM^-u*n~p|vzI`a>HS_!&{UaU+;Hmfb-x zb*ODWJX$Y7!yXHj%Ab3abu?bA@b^84*EX5i_LpvksH^ZSt0SWVnr-6m5E=3GAUx%n zVhvd($8>e(5uk`R&DY08H}aEf#)#9&Wc;>???2fwV*gXz#K#VA0Rf;%lAx!eggjn4 zFl`@zM;L}zze_IWc>v`MnR4*G1*S*~z0{Z{FU4-GU4;Y25QTDkzTGSw4lU^zxF84B zcY5utn(ViG7X^NS{r<9S_XZ&sdC^M0vYxuqd3pJGo^5%De!@{yUt*pxHfgQisU>B; zC>DYVP?#M!WlK57L(cBnDLA%-I{bN?CiXHRm+p>XXFAR$8-hZSxV{_*!-!f3`}die z9u~$vycwPzdN8=h8OnYb8#EuM!}?^QSB4~XqV?OOBN&V5zPG^-s>&8QTWs6nC8@_R zd5T0YZbz%6>8%T5$d3kN&dM&TDpS9MW+klfWN9I&T$yH6 zE6c+SUqY~b5eEa3fu5BOI z@^PxtGIU&aXKi9KYnx|nIUll}Ol?JD=nuhLsHJ zUoZ=tvCBBi6b5hU7thFIWEBYcypvjAqiOu7!K0d`l?3T@1j&)lYL4LNe%Rt7kCE}yXdVLux**7rR=9o$KIZ#}CT*Jtyr*1!3a)M2qWWw-881wvBQHdQE8 zFJD0EE)#s> zSd?cyt;lWzZi*uykVC{qcjV4DFRoZ#GG1f{n9oitnEqW@aO$SZW<;dF@h}+aW5b$I>rl_;d7c!4Dp#q^ z;dXMBw8VCOJy8i$R!Oswju#`C-|;Gx_hV-bqvH)HCy70+$V~3#EXO2GqyOh8iTjI9 z^Vux5LIs`XkD5Hho3RFROE3G<`|i7cS(9}*RHsCrlMW-OqcN?xhDm6eX_Ym!%Aq4h z<*buxA?70X2+j|qT;)`o1!G{z!bv3hr&j|kjJab`w61|}^zGdWCtu?->n3#$zrBh{ z`XWDisgT^En0f!5Dt#x13?RLa4vV<-3C19r_k{+zCHa3r23pJSwOZw7?2}Jn^^`NL zX~g}VB6&~y6MGHNQ9b->9KL&1A8)C~xMP}5rr&xLUM3w$13ul;*1GMaG@4Y$Qv(? zoQbReX)h>zC6Lz|h9v1m>iK2-=7}&svEep!r@bzIjLD;cY@h12 zPOJW&ot>Uwxkoz`jEumk5DK{j0eYMC4H-M+dPkx6(8uyRO`7C7Gl~Qq0;+UdOnyF9 zeCc!>_3|S_7OTxV5)!GEq`|V_S9jg~31PRH&KO2Ey2rhNXEvd;;<$OG-U6VY1E z9j#3R0C0X!@BA4m*ZBv_y zU6czaXl?$QRa}b~sL}hHd#dS&zJ(KkELR=y=yr*Fr2oLP_Tir)=%dn^Lhzn`HgH%R zINhhXgK-T*Oo8f%WuL5b@W9g5<03N7Ee0J&6CkZ;R|IFvwYy%)S}#fz6i?mGi|2#L zNP<#k=ntY;cG-}G4#tmjkEH!a35=WRz&xiIiLY;h57_A5!l_A34g>7=&L0<~iaI*E zPdD-jDi6=iMRQwtdxnJmiC7*SHltsUf1`v)Y$w-^*U?!wz0BUZnV;@GXRu>;Nw43% zgXWd@JGdi+mEVrl&tWxFL%T0yq)A7YG1;$4BpGtlj)aGw{crGS<{veFyysDo%kVT+ z4ZM(~6wz8oO)^&y=2<7=Gr4R&!x`q@o_?wAkc)^VGu61}GD~v7#*QIl^v4CaOACiN zelYhEK4(q@O*R=p@PVgv1zpU}^lr5?qsD#_Zc2};!3BctxF~luCT#-ElM)oI66U1o zd)3u;X<~bxWM%ZlQ-_}FI0DfWEYo2~QoMFe4yr!X2Yaw*%SXOpcz5jCkkrrY9>L9MaM^*}YEh)56)`gLhbRCH^tUtUcAA+0|#5xS~MCiV|Oh9Lps zTSIgDg+(pXQ5#6a%iXEMr{#g@!43venZ;6Djp8~>F$$y+`dGy?MZ{&tLVKuAW|z?m zv~M=mH+6P5Me++mNudFG@TAois`@{ElVf^vWw-o(NqKNP-N=mf=+rTjvr1S+_F=IHt$PH?%3Z`vDAuFO z5|(=Hlr^f&QTG5y!j{=xM`+x&%iqppot_S9ip~q$DIT8p@j2c1*l?04(-xDN(sgoO zcN{|~L2$j@d1_)(rFzxpL&15GPMe&@IKY$CB5}Cy<*S6HcO{bq?B+p37HX#M8Mf;v zTe(GiEAlP2Wy6?LOq=r^vS2fF$~r8S?$$AuczIc3ZI%{eROjx~Iw#e^2``;NP7*g! z$)7DwEwaP?T);U*f_Cohrf~|BEMe4k$~qq0!GmEKn;RCWdJZlSxHc2RrjI(JprfQy z)~wNQ6cy6n;8^5-obf=(T2EVREQ$Bj-t;i^y@((G(RCKz`V9S=SbXZm$-!9Y&^Wmh z#_R=O06Y-ph|8_U^4tuM=;gw^WA^KX$vY?-D6hu09d0463qY@zCAt|cKub-xi(n(> z_B@;Mf4Fmf?E#v=3bK+F|0H#I0?Skjy1Ux@`d*yWf|=gI$i^BeJTlo3AHE#Fg^Q8d z`Y{WD`3dB8lbb@aqxz-L4lochLgX?VSHsME9G`$d^}@2_#{=Wh0Dh`IgSG zNf_uoe=`6IVt&<{P}U6eRs*y+sz}hbpLZM^6EzyOuZCVm6sxX!EQ!bSGBh7Xac?S) zjk}VT0=UGj7V8ePBkdA~|N236sDf9{wW+O?Y6p@+PgrLlvmtXZJVtP@Wvs8@(P)wZ zAy|jAoXNvFDo*T)SBmtK<0L^vd%g4QA=&w@7nweB-f=R{`4H{nXS=N9mi62Lk6%ZC zJ9u*iVWy7Lx(JKqs;thZy4;K>3|s5tL8RyPc!FUu)3W0XdW^5t0aW{qXl{>&|EVH; z?*+#ay)u8)zMCkJd&F^G6`zp{^jPs?3d;C7s_uS)-ZdId`L0dBa;pM$#p(l8i5Re~^l>AiC`eD9J-$|-h zX!X|9H8?49)%?fF!134_zfAm=^9_ zzr~v$ui>rd_uTR>VSk|?`mB1%a<28j6SdbF>85*vcG+3Jy|I>qFH`IHCx3*i(u~y! zo}RLqT~-1{wI6+%e|kvBDR3ST&j`brOD3?aI!b=eT&P0Ak{*4ZgGgu5k|-_BLUxd| z@^p|V)mBbX^|ctDEcU5p%@%QZJYIf@cMxjMX%Z*MPd-!W2X_L*P_viZw3xt_lFPFk zI=tW^zV2>?YeMLQzf((_-0gz-(b#7{i3+AiKGqs%B>S2$;=A3qKGWoNN>1X9ej}P? zfmb((VBVZ5AFVGCaLphgU9eBzO5?UOM9xRnHcalSjc~o7-f_7W?GbWO6e_iehZX*|Y!N^9i>bQ4ynf** zsxmiTewd#6?0vKSvxl4N0wr)LoUU z;FG<-zW5jUq&xhQCsW0VDK26Sw0UKzyTL2>eEqc=iUa)mMU{Wl;OE zx`)H+JM}D6pnwad5XscQ&w?BSIAY}sPt&8mX4E!hW#E4gS4lbdns*E3x)s+=5QTc&=|*!{hL*bRdtpVTmw(9c}1c*#tWv;U}Qv2CXeS}FOB;uvRyrnY>LJj zq5{P9${u!VCv!TjMPZ{Gwx(om43Vs+hA0Rh`9FgcUohXU$l<~SROILdffNIOCjM#t zfA!1>w1T%2kp^110hn8|)reT1AEzZLDzL@lN=)PP2cp(R4P`$YRPwRbt0IeLOb*ow z(-yMvKvi4jM2#^N-hR8yk;)gdlTgdkQ7!~6^Fq$u>AWc3HY=yFUQ#&N7suuC=Sf!;WW8UF0aq)$o&hFiyO?|yIq>n+zL{g21SZ$jL?b)ycFzw z0yk%C^{&IS)7r0RBc4~6jQNF7_jhi3u14bhBNK^Z%St)muV1E^+8HnDi&&*zc4p=$ zsR6_0=0m2=wwB_xpJK}mNgsrWx{>wVF^!PcQqsyu zTzQ@Nu@VD9L&OgYyFITzHu+!fsq63c@lJ8|jh6Y7wzTtzX1GoFs(`^(#ign<#$B^g z!GtS3)5b;G{G)w+d_}+--%R{nqDy?RCX}Ab+g`-d#8g%5$sSHSJZNIGxlA{!vdRM? z@Y%SD7-6jZMboL&p~|5%gLp!Nl|yrP4hiXcL`rV%8oK7;w((~j~( z*w^}o3LZh~Wc@$&$-viq9xiIpx>=T+W!*4iuXa4zTyv+8Dsi90f8nXpEk67#IOoP7 zSGivI(5#iGQ8^nb!LA{iC83pGT+;$Tuhy+af{laqr9t}9j;{s>q+Ql-0(*dqY^P-Z?=j?msGJKf4IXQF=N~J;!)M1{RfV98}meF*T z8TyZYN!6rHEU&+fFte~K6aPr3IYl?sf%)bQLkl=5{`jNO77UHjwg9O7zS4< z_AQ}yX)@QbS1=hsf>*>k^PO#tJI1$e`0fESNOc0eQ8K6nI90L?nPMOGrNlOYKPN$h zc8KLv{c;amCwIeC!YPJXYrw^&7-B)41+OA=w5z0KZ6xfN=wdxfLTG` zi0fUi5R+yN0GhAR#DcC3fYn`fOVGuI`J}>_XtXv?6)QA!0m}*^+hrU2J&WU1)41;D z^3ngD1WQ0sYpRxwo1_XA<3mG7AEJ>6Kh+R#E2Oq<5xXamEb)tJc^tkJIy`)TKBM#U zyHQ~(bN+>nHv5fNh8rk*GeNn#`PH`6+ap9xTUH$k4x8_+&9dJVgXkaTXjHzM&w%#A~nnp1hPR)#jraZWVBVRm^Rnt|w`;!>xpDqp%&) z3Tl}!cK^!jjr%*PwLad~lxwaG{nK%5_h;21z&Z!Z{KtG_xd)Kb)n0_DBVLI6Lppe$ zo$#4rzpOmc8KEi;RwI!jrXwEj&*`knmGA)dvt&rIve}yC2`07~S<88}LptxdUte$B z)h{}e)ry%PzRDjS2NMF-=|yc)EOIbrr*0UNpH*)fSBNUe0+ZRA19Xz z(k+dt>nc>NMc%X8zyUd+muY?({?hLEbA3+dx>wHGwo(T$^XE7hKm)?NE6rCP!0`11#s<$r{ScYZI^uvM>@1N4dhQTUs zgVa~4IESKqrNfK=^e4WHcngP>O3_sf$tozjc1}BmTi{>KoLw%;X&XSSLMsiIWwzQ|zt1PmRy5Du(rY9f0eFwiF!a%3X zB{MdjyRv%zYhEQVDI386yy2srBp44ST+?dDDzmJ2HJm*8gvWscLqbAQy09~2F$v1Q z%vfy%A&Dr3 zXSi1q zo0i44%2e~VC7*SscAO0Ot}WF_muw$7fm;4#S}(EBeE=JUi8G+3NKo2Kdn@yA2TSl_ zsIX^pY3OqV5KZt^KiBb399DPhrf24jECJkvZ=0p#EIG_wVi-C8@q3rCM}Q9O#nPw5 za&vj}2?}ymi6AhnC#2ZaklrMgN-Y;;b%M@a_(!Zk9^G}WnA&m%(VPJw6DyftJ=NJn z(Oq(xSi449{w=>IS)V0}c#e*o0K1JvTav)PFSKey)PC;;;xd}KlZnDT&Kw88K0puq zn@7>r7#<>=q&UoW6dWsH)JrzyC5>NEtAu=^BDeeNcewD(Dn*e_W%kw4Cx@pcq+R#> zKp*@Keu-cXKq1WPkCo5KJ#x{Ih}+TnxcOgCkaEhm01Nv_m>f zZiwmXUw??N-Xmh#hxSSRBJ7<@o9Eim<=mYe*)dx0d#|8~`P7Zakb-NyQ4IPk^M`yOSlu{(x zZZ9oFR=@@93?EedZNRcfD7n1;lMB{wZMjLHbXft26TryO<1vDVy2Ot?v4`2pgwtH?f%s@j~lBK)7s$g{Wl6CKy zkb`Iz6)MCHfB>MWJs)fi$;HC$^_l2Jmgd4U-4BmLj~%b+ru8WDHbMjJg^2@w8v&}+ zvTn{=PO5a;ZYn8xL*s&>c{c|+W>EqW13f#h^y&!Q!m-l*)-YYeUW)AM_LFT(`?&0Y z=LkVTLO77lwjsgxew;-{@0N~B*e|8XwOL)#@7sHmCqL&eP7!ccGS3ZPKe|(^nG@y2 zcuL1;pyuhzzguG&C6~UfQ0B^#@m`KQ5;a~ZJ)re~aP{{W=l5x#R{*Jk^tfF|tK~UB z{MpCd4^PI`6vfg2X}LQJms)>6!qDS00i8msI5o)w6X@Oq;j)i@fb@(Q?RLb==<0>& zL%U4?S8XhU1CJFci#moHFAg!>mfU(3RCl14*~wyO9h()44d0W<5`Za&vEbRvyDcFT zrsIKjhx@_hxTm8jbx8kgN@L#6Lpy7xP_^QpeXP+=_Td##LL`f=%H^JX5}_;hfVLjg z2%@EXFIuBUUP?*DrQy=9l5HUXj#BkX()#hnoH?5=maA9 zKxhXeDV>qhW4+@AHt)tzEPA){SGGdJxn^>VjyJ74brGV)0ONl}P9qIZSWl%n(&3y5 zq#MiyQ$JJ~lTU$Hju?!?kGT)PpP((XdP%bD4$Hze?8trXIXUaz%7qc9Oss+#k;79~ zx`#@fQ$T`*x6P^1cyUDKD!zBL$~atGFt+B0kK+5_%-cI@c|#xPySjZS!LfUtn&}ym znzmOG01m9Qbl*qvjVSNK*ra3=>Z67R{+@DNeN8q=M!~9IzBcV6B-yLbY}uerV^Y0* zUhVzSA>x`(c(F8{`ZOx!F1~ob*^0tooBlg7O$S(+3wOLpRU|vBp8P*EZjR=3wUB?a zP@rX`KW8~9`VA^H1QCvd)n#&v)~kt58D$Z5xfcKBua^njvP{yQ%4mRmrPgh!E!}pj zJg@}J)^5BR32y()(o@wp?npfIU`D+gl_dwDW}qCQeu~6C!nV@jCkzQ#&A5~(JFG>< z6%C??#V;)2=S!49TJz)9Sfu=mA13MS(x+>|Z%xzR4z6Wy-_LFxJ6VX9e~I=0q?Mkl z;5p16?HbH7E3FV#P5@m36!IRotWtaLCkbi)h`J!I$#C_k`DBpa+ewrMsT*|8uO|CI zq`gRfkH4P?_1&NB?;e8tDCBDTw{yF`Ew;p&e178^<%Tjd@t`O1dcKur`r&^_1U*W5^k?IL7 z#7VoLr5TgYayz3>`V@d8C_=5x`)~c}&1$^vBz{xgHYG2Ds{P_A>Ei7OOb`6lRcZSL z=%!bY64eo*0&N-Iiz`GrR1E&F8vOJTP&$eM3BKRMvzItMTRi^B!3OU(%;@fsqj>>q1aS^S%ny$zwx~wLeX|ZKwoK5pKqx)i zshJ9yRYO@eK3jmF*GvhyN4s-H?vY%wu9RTM|&l zbhth0x;({v7#%;K&BN&)`^Cnwu{(gk;p}Ss|W8d`uRHMr%6!Xdi@&_@rfCLe?wl_ROnRk_TEDGmU!@H`u* z8%mln?g|EX2rAP2d*6h+6BuECh^znpy7d(Z_}T=Hakt8YKUeO1r^>(@Zr{P~^i8a6 z%H>#VQ3P8~BKdln@_Rm1YSJwgD{}Pw8#39dkbYEFt7S!{O-Yf3#?eW+Y5xvWPv=1< zy}(+)tcTANCfMZUy;Jw<6Svm1*`C!r6G+pbtCoX=5$^uIa>iyg^kEOL$6!^Jve3M4 zj5PpHI8lW5)14E6{MZWva3N$~*_}C83+oIZ^~_1?>)2&;KZ@qyUz#VSiu}o}cr}Wb zbB)oP#otsmES@KW?u&Xk+hT(C3%<>pek0I z#R@Z0NfwnWX>_)58oRi%-R{-irY&@t#UOx{;~%4sx=g5t%-p82aJ%TT!6#sn30&>f zss9lB(YAa#LIlQtCFuI3ew zDORO?7AuV`otT+8$rRiKJqW|xB#Y&rfg>Wu1t9`o<-{PKYPQBlH>=f&AR_ znnLv11GA=3<+}&n8*vaw^XUI(%y=?Qk-&W?YtQoeH)HMbAwyNG?E@gC?y!dA{VfZv z<9jFB(@0(Q5WM}r3^RSTscA8nN2D_l7sn;c@BFx%I(9&Gp;wW6bl%aB-O;0;Z#h z7LedsX_2uL)o$y5I~P?S#6u~rY3qRl(Y7;Df&}m%s)1DhjAbl)GfjpyQ~yff#bv)D zxE&O<;T(IQa^t~;$BvbSpp0s%>=%wRrFaUK|Kuq!RT1s@xhZ3!B!DGzhoz6f7Gb|0 zN;NNUk*k-+s5V|b8@0`WayZx{gYbgvu=Gc26yIaR$P^>CQ)oXbz-d@+#(a)iqbRZ| zwRR(~Q0K03>US}FrlR&+t!f#UPo~*91omidx7ZUHi#IVmjxk~tD<0HhH zB$#V&Z6Uu5o>bU$xMGMnz*=likewe^n$9~y^~oGeT`Lm-5S~<>0=UVp!>=$m8a-xN z(-j&>m^4V?=qA5U@ub50PShDBByLLR@&5CN8dv&lPO^bAIsB*0xZqgnR3**}?Vh$e z%(7ZJIw~eLJXzCcp(OlxVxZY#ejm)K*Ml?6)+KoIns!avZDVf^nM4cbtsLUXSLPVJ zJ@ToZ1D(kh?4ORkNUz-)hu0M@p2+a9iCgh33SJI!^vj_%bH&A^$7^dN+oslQZS(mr zIJxv6;s|;SwNj89X~pdZQY311KbRcx1vPxPH#d$(f)ynOds#4de0J!}ezvnG?M+4EFdPVn!3Yb=o{d@}ZV$OSsxmAm?H|C7qH3(f2%G%4w}5_5 zCCWsZ6uYzKa3txm&r3SW(^3U5!68elT16Kjcc{JSldm)(;XTY;k%bEm(ksIgRHaCDhGStg#TE+{$aEGl#`Z{MAtZER+)UuuJMH>p6yPEf)>t7h z7Hxq=G@md-eqgpjdk!B05o# z{OC9E&)1#L8+pJAWbWX&4-50!xXNgiHwEJ@$t$O=}H2=FE^ z@TbX*jT!wO5?__d6}{=x4FO>V^P-nhWkJrH#W*2-u7sBH*Lp6`4Kj`EY#6?XQckhU zIa1pwJ4s?QpX<6)v_6W*aQiKkmg4v7yi+v!V+SwTyKB>$R=H3uREGB( z_^I(-=hpUR2hT{{zYlIqmJ5zoeNY%)->+OWNt!yZkfp!SN@-Cg=Z%zd6GP)eX?sn% zn%|+9gD;Qn91_x7NndtrEny~0p@p*9}<=CoC<*2J#*T&|VQq{^lG*zD|q*+Qmy z;=0Rb6s2>;weP<3O*A!i`L*x9>d>wRlx=&}v8l_(&KQQ|)1n$mS3(_I03+Cp=V*7THswWtVG~$%2lDhc=6RXuBgtR> z(y~R*7v{fV@(AZaGCL+)f;*%fLwVa2Vmu!DZ17wnAtW{Z#0eE6q2ER6&l>Phh4@HG zC`7T`;6Hq9@QG2%p0QY=KO+AK{aH_OwnT&|$_injB`AwVJ5wj2>7-sIFl`0gwTu=} zXiTApWycs!L*KCEW5M^lJVw0vjd{WMMBDo1FOwrWk@@*=SbUr}?cjUIlp)tW7fZhR z(@)XB((T6uBdb5&28yVC>ZkFPNFGxX!GEN11)euxRBk`)R`tXQT}ujy1dJ*^Y6%8P zA;n23Z}_&L$5HZqyp}e9bS)9FbnTBsGp)QYi4$gzH@<}2!>tAyz;&%^C(C%fvK79= z%f3cE$&vZsMx12mr@NvJ*b+OlS<$3|86VHBDvV_oC&%((LC?wf-%m@`R=Cq93qIZ` z=(^;6#Z>@J5*GhEmNT;M=E(^=PVNCA&lK}xlc#0J3afH)9)|@ZleD2BujiLrOZB{( zRYHQhp)na6m33V(;`YSkW;bi(Vl<*Sj^^YjxeH|eDar*>=Bu5jKDH6l3@gpt*V!Qx z29lE1X}e+EGfN7qpdUJ`Z<=i>j-{GwBD!D zlcP}yyVd=U;NX4aCcvJjhn!K4g(;zo6Gt~U68O)O52+o&oTi!BZ^%cJQq(S$PO@DhtY^I}K zBni-zAcgxN=x_9%XYDn5zUp1Sc&-~G?MkwW#kCmY=DF?M+017vG)`5z2_drZoLVDP z4ddA6dSP=~)zC5>YBGe77iaDOcuU-MORjrQ-1CZ_ht6Mj)h$=8gDyD(PM^PVc%$D3 zDd=UeYkb%FyT;48>8q!6Wh?^>lvAmtcScK|%=YJU{n_yPh7Ya3ay@)Lw5j`ax51x` z?;eNGKygAgO?jfI`qn2Ytifd<`u43ZZmfZg zmP4txLO@|ZnL|Ni*TID7JA!$5GLb3g9NH--SI#6R51WGHi;08vg?fEqh@ipMgH%~1 z$YQ)0r*=AYIWdT~Z9uckbkSWvYZu&N+O)KRZ5rIDFKl0^uNM@->Zh@M`YlN60##@V z%d*`9>A+Z4M!V~}5;4ET$b+Xd+jHh2^E6KIr)jv~LSdC4VPab-`ct0-*&EFC<}3A{U5Gdd$vWA z#8Ch^d{ZZw$Fh=0TV{&Xk4E#bKx$ltp3}7H4?RU*aR6Ga@RW6>9tuIrKL+(%b+4_i zZ%n9!{1X}kSSA|2QUD2r6g?sNFV&2drJew`?v)h!OB$Nf?^L`E-`wC;Af*BAua zDCqzg1VfJD|Dz|2s!C*n5Xj|8!7IXJzU7BMyHWnWY{==s4Spp#;HVUkRc9bs@iz>n z;YLiRT6w6pY(G(fE|t5#I>Xq^aKA{=DUHbgxE4Z$(Ciisg|UW`^_35y+bgRpmDL+p zm&>caGn7YH6$ULrEQf4=?HTLFLC78bqg0=h9ayy`fjkXXzM0QA*U$@=%J5kD?051* zL;0FV__&E0S*Pf~wl7t+vzCXvVme>{ZuAITdV6RC8RmvLuF6hS%tm4QqNr^2Khw=J zx4h6RlLq8NU)6K^b@qTOl!&5TX5qAZhv^He!toSO{`GR48zK{aFE?p8Ey*++GOY=ZeMzHDxV-m#$NJ&)r9 z8wUqB?%p^surc^`W;BED&WvP|)#+W|+BH>murmPn!U`|IUHH_zflR%g8Sr{%8cuL& zuEEW+6UI0UY9Ahq7kX!|z3yRbs>BLI-j3l~U0A7oeM>DTo+XI?JhWvL4{jb_`goLh z^v{dqWTc3xq41KQ;^W@lnt_$KFPQtav}wVG;&1nW*VY`rqD|?EX3~NQXsOB z5$HI4v1%`N)wA5P40{jAMFs9!#@z#d-}`k8Jd5k37EV_IRqD)bW2Ji{7xB!q2t8wIl6a4377Eh9GXs3WbqvDaBTAZOPc6fC`pXPi!6%ERZ5cEU4lSvZ6S8Z@g6rc2U5_6=tQka3 zxGK6nX1cN@UH0iGfhi(H4=uY(f2upGpj@92mn~-h624j|gmOwciQe#>?+L_PBOyEboVFHm}gITf;Z$&y%kU2PpyOl z^m2di5c$dj<%vLFS^8vD87_mpC+QENEi;f$hCdYdV#vADA4-G#UE#fG&wZR60xp~S z#ZvrCPh`YMGTaYy1YjhjYMmza&aKX}Uv?Nn)iKN*?FtA9F&gBD0apx#$GHHM&2Iubs#Mo1 z0FkD~(Uwg^64j^z+eD6?q~V*|%q3Yw_gZmuuLQLAr4xoOq2H68IFPvDT8UgE%E5Jt zh~kcneoxZz&y5&)SdtzlG2#+noXvYeev`C3VBl@Q2_3*uO2PZ2tm4JuKIq`6 z__4LUY$_t5*p1bvVx$1R3DXk~6?jHV1oL5tiO-1Yx; z#3_LR*vThxy=YLq!O`E(42M@?QxFZd(uj_4)Mzj-3TNS=A($HgSQfg7of5+D-LZb5 z*56-SSid892-0=g7@7;+1GN`10m>p<$+_&8T(Z`W^99 zaNawDYk#i%=z3Mrjl0nE%bqn)Z(z+B$*d!?);9p!z4IRoUun4hx*j_91vIpo}S|)n{%cJNdkiu zM%bNY|7ketQ2)h#A#jexkuOA%wU}=?jj|75PTuU!R4Q(UFQqFJLw#9CRbgjY;E<6A~t^hvpx zi__zK)x=dD9~MwAnEg*`a~ zdiz0KS9e$4bCZg>k66Dqh(1VO9vs*-XGM0=_kG0{S#k7;v4MWd5X+f67wz27W zGUn74)f`Fif6euejP!Fr6(o{$m2Eg2=8qsF$Xd#Yp~N;w^w5rBCt@h(q%>LZ|2&76 zp`RP-QqtO`s_}Dtn~QcwbGc}bpa0DugEW3nbGa_aF)T2VASLl$$O9h8=d&Ego-gA| zMQ0cD@;+AL;Skr*d3*!S;%_WNsjxGnhP8y&2)9F)a$&uoxksWi9@yTwqN8KQmX#eH zEB!~(ZQ~ohvvIsFHCXbeN<;8N2}wx-cgM1ULcBBcmC{tHG_{f(pwzLFK7$YbuZIbs zN+I5s8ZHeD4Yl=^vegtwIr&&urW1^nCRa_C=%MZ-OKfk1h!d$s7SnhTR}#9GDg*kH#8DBl-PkUNnqw1{z~k3X^$x=-x*b$?qAlA|K__JK^py^ zWP6uBwX`Rvd8WV1jGaERd9oAFg8Wzkd;}lx9O#^y|4qW&Jk!5CGnBcF>^Zk7rW`8` zE;YUOW=x2w>TFxv?0T{g!{>{nn*MJzBcd++Q`SzW?W~?9@~Z4-ipdTq6Hgb$^0<<8 zJmYjEi))FwT*h5Kf%#uNe@x;;WZ?PsFAdP4C#y^6mf{N5&$c!8y!$C8*Sg3iipv!# z7l*4(L$_xV+@#Plw;|A+2E^dI#Y(p+j0oc>g3$ka2vqUNQZqD6{SFQjm*hI91iyJ| z%O^9oY&g8MoM#x0k+j})T*a%*=S@?#PsABcVz_3qzp}R?Nw!>ZcCt96j)eJ$IFo68BmIYWChTx1ifHO38a(~{!{0#U)R;U ze-p6WZdsH~_-U3!;7^ln_!0kG#NZwr!o}m94grhFsU-xJ4WtQ$=QO&DUp?+GOMK!eu$W1#TAJ5E`XKdlBjV05%nCs> z2~`haUd(Eu2eZqa0?^b!)t9m*Cf$?p zKX=i#tpkGtJ12ovT60Ij0&58NzyFez1o-9X+M1%tpX3GDm%Q{T4|4VjjUCL`0t-ltrsH0Zrr{Tz4!>X@W!!~!Gy1z$gh;noQ4T*G7^ z*lfj7Vij2{&r8jc(|iVcI<#U;;&>XR3KmMNBgwl`%gL6zErrq~?RW~g+UCnsmwzH- zU5VFFqHMTbvohAzx0@E9GOs3S@j~ntdUxe5n)SQ8!*%p$=mQZuQo{S}9JLxJIz$cf z`5qXE$nxx&AT>pvDoiwpKkZ^&VK*6uBqzE!%0>~M)EXQJfX#4Lw6$%v3fq=c7Zz2Q zv_ZUQz%MPV&2@L@;AhG4xuNYtXn5&TmCzQe1c_rs25 z?F&C51(2d(p?z5gT-Tjj_$OLWBG-LvX!~&Az~(-1(lh+W4c9d;znmPw4#V?|haWaP z|8Y`Q#p}$q_|D&q_Cykq8l_z+)k>+=z;igj;+_I^I+iYk_2dz{MDiTEft?+hom(eH?ZD14h`=6I&RDJdpp}%cBa8=JjDb?*af~M$=H`r)TF2F8w_*)Bm2~z?cErb>~ZscwA?{vypQ} zsH3mvoFVGY(0|qf8NEl=_;Uf)EWG&I5e6>=QpgPr(;0O19D{!AYv1$9xcMSk)Lt~> zC(Lv-nkJ-QLylMP&+<2e;9~_IwoUQO1gHZq47hI;SdsYEQfJx5#Ty3i88PG6kS<+A zpCe+Mc=+AIe?c)C|1Jo%i^7n*L8x6TkT}8TDcawnPXq#zzof^>k55r)1&UD=L4Pkj z1&D%=QSCu6UZ>D0%zyM#4Q4&SM;)Qd1628ox29=8#knYd@|q}en4_>=tmn*M zyMk8Yw`~QOY_#Q3h+@`qMPf*CgOeTW7UhC6C-Nj%ts3$Nv518^gkCD6S&eDkotRI! zJaT>HJ&}i6@Ez+BD)HiTc6dru7Y5?ZtX5ykim$oIzs6Ff)zYN+I@kHvu|`3$zS`AKboqVDNW8qgtBhi5T*O*Y$I@+|eQ1+`zRTB*IDmndKrE&&z1lg@OF>$SP8x zDhRN?&OMF!+EL8aZowl#Y?229an^7v&QKoHBE3O~2_mfWXgo_I%Ma}RDm5CplZL|x z)P>Y>xJs=5ydq?`!K1w!$tyh_>W)nCvCi|l@|ZJ~OmSMRl3-SIdm6s=CR3gmn^2Zw5DS1y{Telb9W3W_HBz1lZp{s!B>+73O9> zOAKfgQWCG5a_k$L>d$F$UD>~1(c>EUjH18wvmqTOp$f%QtXqP;F7g_9w~Tr1+ZDr5 zz^mkVJGn;{Q(pKBas|7{v3VWRN}XsqEml#}n?1xvsW71b~CI(`wVj+Xc@ z2pPeDfdRZKz>5Wy2TfY>Uc9ch(YG@|eJBgmf?Tf$m<;gm2xtQgXq+HKWoUp?p?);f z`*%8wO-cRq3W1NQ2k!6PQC4C_#haL!@b+a~cXEC{GoX}WYESX}{X3SIO2vKU9iYE| zTd^;1M*9!+EuOlzM~;>86SEWE-mK$fV-wTkUaws8DQ+%oEzG}Cwe^}7q{uH{M*F6pHgG?`}FoNs3GySQ}DL<98b|FxdQT;qr75soWa zbq057Ni3RKMxCN&dkckK*PQ5Ep#Zk`YI-KAIM3pEYaUnE#gy^C=D0xF za>?Y<*<(wKeSORO`p}ojFPGeT-9x%=Xb)U}*Cph^dHnmf>yN!#*Kay@-8TH(Ns@PY zU-O$J{y?2UL;En=Y8VC*Au4!vY|m)FD-u{6QSHq*MQ`cm zrC!m=^r}Ik+n=3S_c+B|b|*JaqWeepjvY=9;f%N7Y}sBV$fopW!=$0~;f}QvIsb1q z2MEubL4%gP5&a#e{VJF?kAOxqUIUODbS6JM1w(M0#!?7>z3ljH$CtIul)Kh-gBQEk zb(LrQzv|oA=YJ6NZUmPOmi@l+;9wcNSRRCTZ(4SI8UEYE^{i<3pQSS3Zm^_%MGp}1 zBO5m0!$PbyG*kjh$gzP}bAP0V+LTgo{s0NPA>1P71K|uOKT*Opa13L!jH9D)(&s}p zVmZK}JpE$NS{mm9z|L$CG377uk+3~p$Ewc1s@urG_(|fo2@rcKbdlWSzY;iud3yr) zD|A0G=O=`)ob7@QYZ{qyL}%LC{NN_k=t8p1`fEF^`VoJ_{82IPcV| zPTs*J;V|zF=ZA}+1Rgti(pNTv$Naq~Pl8uB!{<+&JbB{uX?kqVMZ%}7jhuG|&qcvB zdO+wwRB6>;Q2&g;yP0nA1ezEx9<>Z;;%`(?iz|N%hkRmk+%(0E(Km1=JCgBoR@9a? zyd!rWKCsLxY&>*f#hvReOnSZKxqk(@(V5X4$k!Iv@-1Al&Svr^qn^Rm99+|u0YFmR zMDg2%QsDA>dH(PPE8R?6S1$zl^z=p79Hp#Iel(vST~f=hi8@$IKc42e+hE@7c~nS8hdIFev%O(&(!TdKLTIId{i~`bjEE04F_bvzBUPP zV7r)bG0o`TB_?F%gCdM3WER{WCL{k&F(I)-5-^i812%(ONq9bm@y{nloF9}D4nDo> z%+z5qiBp-TOn#iU>0B)W9|7l`QN09k^{-b?D|Titm2I&_x@V9TjV#8~WDy%I2M!eE z4_W~PS(O-4vU#dBcC(C$mpo2q^VGV^3;_hEA^d{BE1f79eKA46i(Emu11~^~25!m3 zqK48J$4wwsa1al7Y``eQEq^6=R&_ZR#T174c|eGi7>DWdihq-*S|%sW0TiSOHo6}` zfsgm$2iq6k8})$tUY;7XLd3E0KEZTi88F=EMNMHajvD~x0bWvp5|ag7-cP$?ionGb zMBt$SVq}J&gG!fMPitaloWeA7j(W5|{q4*_m zf*b*3)bN0sAbc^^mrC`Gl7s)G8C!MDeb=laTtwTQ+whmY?asE27<`4IiwwRPoWLl4 z#RpD2KUWHDU4TBqYl5}m=RSD+j9Me zg<;1V62UJSq4v1s-`2Q%qhi7n4cTb?99Q*y0Iozcp&e(W{O5h#-;!F&C9#$IU79D?MS0_mPlMr>y6lovH|#g!>c+SLM+@$ zVp7Ac;-z>^Eyfk3HVi|F+sWx*kGxG$ zP7qM_;hz!){inkO8@<`)wY0^3G!|)q!Qb%yo(%BaYwYW7*sUFdf2Nd_cPjCsN}HpU z;;Nr+ZEt5wN47LOh!%&<8=-5h%r8tZ(u`n;*pUxfJQfd+YMxo6bN! z1B1mlh#m(q6B!IErlNRCxC?sB%1`ay_+o#d z*w>(C?nhmCpUY7RzFUt*L!sZOLmzvFuhK{c>M$_SgP4=-fzvRIlUIWU!ARz`N8EVa zeZ=YYuZkB6{e=R2MZ{|bht{nVfbL4n+I5WJl7zWA9%!D(tXqp@&G0oNX#ioj)|L2G zqD$MYD%TX^FB4-Jd>G-N?ug9XoajdBlZYe;?A)B-nTTW7tqa$nQ|M1HO|~_1AaW_O zXSeEJ$^_!GVd9lqSmA-V-BM>>5d8g-0P0qs3^(o&eq=JA*GpM7G#4b&>zGi7*vD6jeZ<5{)YU zi{#k6&X9}mk^}tUxD!;y3e~U;DSH^=ueK*%br$*mjaBkS#+taZQEH=p|7Xqm>$QU- zE;SyF-maQv!Za@?*YOs;=Edk@Dg%#L6n>M2JL!cq0*6E6$y2M~AadjhMZ8Yn2-F6H zuH{Y6U&+D0Qo}7G=5?ZibFey(uH{Gu5j=%|-ml;sCeFgu>9bvElK5R% z_188KB0&fLH5_m&ghrxDCZ3p5;5QjKDblCjwPnlam)twl@0hZ{06raa1WhsAn3j&` z2K&Z2PUamm_g@g;W$ym_R9$oQ{CcA(MywMDsK6(+wV%5Cc-hh+RCwET zin)rZ8IohBACeKzBXHn=AaFcLurep)}1<9=u9SM|MRnCRja9G`EIj6YmXTmZIhn z&ZjA0uhtP$s-bupKWb?~(6|5ro8oL_pyv`$9h~fh<++3X1JeNAyuEjTMB2V|)h)H{ za@vu$gFyg(I0&}k8~?!04ePpls^zyNd5+aAi%%?dGR~*F-}1%Um5VkW_YB@)kf4~wzF~jKb)e(!QF0^-LVH?0N?H|J1SFP6L>aO)u(S(qvZAwlMH{%} zP^L2re{(9%pnNXhzVLO&+M{=7bhLyV{qOlt0AJJv$UdbWpFEgeudLcO?th2XqQ|^W zV#nFJO`qQS@i_uw-{d8Kj@}VDOssbTWPm_kNHxV23w0_~@KdoS30+6s$f$=cr6Q&R zFDmMB>(#vn0w=)$m8f2B5CV+nz~ZQ5D!ip9G|Qum!BW+=1=Y>-k}h(l7>%ihXDNbd z$81HinY1ojoFga+&rWKh3vJa=MhP9aIPE&Fp5~0W8ZD*VbV66WxR%PxJgmZpPS0&Qw*6Lf!+64@ZMrjDr{3^53#_c%L-Xl0-;|h z;UFTGEFkW6)c>s3GzkeKjMu}b4uz^N0yIo4i7-`{KQqc_D7OJuymWpVjkf!}?Tb+E z{Fk0wB0~u?zAp$eRG0i~;0hIOTiL55$Ev(zFqA`L3q;L~f9hWAzqbVuBoI7<7mgOX zx^Q`;?d=1Vqw{l$!4-G~09rr~Egq@S$XYyBB*yxV$Zle5g!Et^EK_46M68Xnp<@Mw?O+M42<|z7FLGE7 zd6vgjs)(YZ<3Z&$glA=y_;w<;TK34M=dtKxOsZ=rfpZ%@Nq_%fTe8H?u$G$}JQd~F z4oLFvZS^k<=69d+Qp^mh#K1>9P4=Hj_T*Hi{o}C;vjU|&@R3*rwS9ml+G|WE6}xSV ze~1GQDgGfa>VKC5or?cm_=yC`04rHH#Z2?AK9uvPvWvXDkT^Q91XPj!?^Z0q;5z=B zmu99JOScC|mju8e-VvVFWF#9vleQ2!{;vXgzOUf}e7L{h@1akAsL;Pz z_e}65!4VK9_nv)1#-xVy3&{Ya{lE7yZTPc@8%a?QM=u0N3WE^`A+_XF_rVK9$Fu?8>mFC#c^$_a7{BmTpV@!i_)F-Q`LYma02->0@M z-_4DZShPgIF}eqhF$>WkF3)8oobcv!oQCOHcrQ5UKX1eU@`lf^?IL(_-=$B3wlL=V z=KNRzW^4I=suAcN-8UgwQ-4-1o-)N%>Cwd!_MkxBonD(?iufq zRz8TXgx9o22<`w9liE@O5_@|vkJ%Igx`6`UF_-eF1f_)vp9aZp&aa>IF_ zEsW=5W(OIPPJd4<3a;fp?)MS}2Dl;`b9EA2=qS#&1CNV;D+xPFkc6W# za0R(uc&5XQI}Xgay8o_Nbe@*r=-{`eDc}pOmpDb$=lA#FuW+xZC%jb%on`7DUMA38 zlEh)+OhW@)7A>wZxkrXsYKcI|P|xhuihQzo)JG9TF+_`?Q0$n$m3 zoKQ4RH1_Nt+iCP*k3H(GQgy+9TF}+i?wH-}#*RO9-+d1q_rL=8M~=y}-8M2Oq^$YS zLuN*vFp=HuEw&Gp@V`U$;v4_y5o^WhEky={h*(s>qJ#cs{I4v<;Hs-v$sDif0euuK zZA7{jP*2o|rwZad%LEna#uckCGq1J=5^w97f}pghc>8r%JvTZopzLTKyc|_8VUAp` z;aO~I3;%m%G!FP7xJOZ-qoMmJx-U%*ST|n2PJXLp0hOKYUSC|4N5c5%b5~t=dttnc zCpwh(!F~|{*I1{!2u9-O)t50x6q7xy+c46Kmu*p9(b98Ugwb#~Fkh{d$(gLz!|3(n z;hb%{^-;*{EW8~ox&shATozuq`;%tu6YyrTh#Yd)mpD^RuXom$xv0Wk6hrr)VfICC zeghfEp&$Pk?IY)pJ%CqqmjT=HGW4rcMb}~^v-e#^kDU3Bf+rjD?mS+T0v%)zXc3%H zyC}lY;MwUqbuu0W|O~WlITj$bW9gOh_)?wrsVU5e203cqSvr3h)6hnd(j^;X)~$ z69U1CkV}_}5~DEs4oPI7ZVD1>XOyVq_9#5jmhf_qCqvp0QgGK#Q9Ox?CNOrGlioI}r zP-gkOBin767lWNK$p4vt1q8@N1_IEPYCi(&U^N$&G$aB=PRIRgDc%I#Od<7HbJse%&!vE42iWQ?(M3@YJ*9Z zjrHg}W8wq_fA-E?6QClPXY26yvodlo(U%Xjg!>94bggr%#R#`$j*W!anpAWb%u{P{ zpf*5IbtpJz2w8`X_$Rzji#Bg&+Z2I8EC)DQA0)~;-k8ej zmgu(cGm*qYIi8VStGvz@A)vbTZv!aW>&liZXJaztC1mbv*9}Y0P8oRSFZ>;Vj3%(W zl=W1>O^fbMS&7-aSaNv+ra?7-9Pbt--j0ddPFGC3g6d@@fe|zYg7BSv3w;ak>jV{r zPGYi)k#`it4DAhsQPhN;ENzft91<}Q1%{xzTQ96|@H?9FS=SK?$vd>!Icp$#@)t%= zlsAyZ{^Idybj>3h%q?2y(8&13a=Sfv;RskMEzfa!^uIF9XCW)0&k6YPMN>n@md%f^ zk47K=1$olwvAAD6jtg6}RD^eUIaRp&a~57>V(tFtwf(#8cKI!ggr7&K8GG)*NQL-5 zoVyPrK^I6jZ4Moilq;3dS-n$#?+aShU^OVS-+Yg*Uw^ZFbKUq4ll}C(sjuKGg}y#Q`uBC@W8aJA3*Ps<0-6PwFYrGTG6{3!MyC1Z$nleR z#PazVJ`_l^goe(K(7gnUcs_DnWr9imw^$ z?Dem?8hpYO_=?OQmT+E>LOl_dzQ|FNT{?$ID$PS3w7q^W@UIScSDr}M>QFT!m0<@AEhY1MAwLy9dy-ZS}JR^jcGIv$NP$SEd>p;fF;#an7j;!*`EW%H^b+@;Cc zlA-E3qpKUrOjhw^9pZhf}k7RpD93DYo!yM;f1xJn-wP}=1O?8Bb6B6NL4HpEhUj_dM=8^ z4CgOZrIgbOHmkLD895$3A$SI#r>z$f2|Y7>>jSqQ8G}HyT->DP*KOMRg)Q^fFM}{y zaG9}PH@VA~xwj?csV-o2O)0za>Y|GNF9eMT?5$!(sL%U0bk#ZSSRwRuec@+()DT4@ zijMgokP4Fj0bGnO!WTqc08P5??dW1^n^U2lYk}hy;fDrWxA}bQFW}W`tu|My{jO50 zt*g}*?yb`EiCPs7kZ@BAe@>47&^Um-TKIb*zNc$iamCrD`B?reL?)Ji z-_uV5XO>jYMA~y>uGW| z@8}CpH4DKEmVbW0dww(;J56w{$<3ccV<#FsEoh^mW~dI~_s9-(2$mLL5EC>mF^tEd zgP#+X0rOS6P_UdzFl+FDGcoB1c=StF=YXbi7vWMFAnTmLvMa$? zfM|HC|8>2f``<`K1rZ2J+D;`(9Y>}_J8FwlM>q8m@%(e~#}2<`8FSCJRJNLo%ztsH!Gn7W)p4dxsoX)5fCN zi*`e6tnj$u^O#LtVZ@kX6UC+^*{$ff}O#Pv98 z!c1n#ByKkxJS(v}j1$RzQ8yXLJ1zDDc~sZeR`I$6FU$8aojFdH1UZ`%q!N&0xo1gQ zo|h#|1m$_DjA^Y{c40FOY^f6x%Kv4n-C#$n2_d?_XJ!zu`nI0eW!+x6kvH>k?ObUm zK#W*V#C}VSC}RE(sVxGCHDpRZM3)6P`BuZ82-8Wh2a?aD2bRy7Do-4E`vnslt6Jaq zP>xaCT&;HA5X??o@b&`}8`{oHeQ@G}@$m~Lra$}GhC~h}`oQzIh9=RtIG{}#s zY?_}K`_lY64d=qygC0TB8yF5U42)EGU=5874^lAoVs(&22pOWF>cxBz4xTU8dMHLV ziC|J4v?;5M1`+v{qvi^jpCYiU6a*&=sv8jFAl+fmHbKDGaY(K=3*b*KL`(`$t7+&iO zx1<0<`vqY?ZWpBO3U81F9(Q=0PhLhsUf`z?gYQOT_%U8w=|75>cFYNwn3#D)yLuLi zXtV8dmK4<<>w_|wy)-$S@_)SXmptj&pmR zHdus)=)N!zcqUU1^T2Nz4#E$YCach>nzg|b8?PH2)vE@qPL|MMVWRZ?;{(I}vv7I; zaQE@=mnI7JxxN8yz~yx{U2F7!Y8`dc31K*+gF0&8(X4gcc$pS26Zr2_uLGjuI^uA!?VZL#WB z{s9IGm9OG-yW3{B)0qmp*mA|9v;!u^}35ZD%ZO=NV(( zXlYyiWvjz_Ip20hQ2$~Y zJ1@JevvGww&%CEsVOckz_eN5rED9{{@<@ZlAVf?hXyyQHKXyZYl*eDkYQA@QoB!3e z<-PoBMom}k8;*I4&{cb4Y&nlK-QQiEE*7V&;KOz`t)SQ*t1-UY=r|Cf!{zGXkES7k zZi4TsF40B@6)*SB%kx_bSG+rykb4Hysp|XC*3RZAPH=0muOA1FWV_PI}&2l!P;X|~_vaQk)Lzp8DqIWI}`=HfPRliik9 z7_ZTXRp@BM3&uPRnr}0-;~Id&FJ%jb>`R&Dnfe7f?<#Y$_-b*|yeb-PUWVmeirz!x z-%vRetRq(GjTcJI-EhghBnIGg{q1g9%`gY^eNiz5KuV1EiCi+@iiz&PXdq( z($N~6j`pucASLw81ql!6_apjf#M|Ul0B`3E9Qz9EesJATr63!WxblHv0y9tr3P> zQ>djG9<@hNCOyrfm^?I7Siy2D^0UJV>R7>}Hfzzc%o2`YTbS--B*4_7jujndFg4jW zU0lnvOVZ0Gtqz7;S?Jc}7=#H$>CT_Aj%7o6dVNX+h)&IELD#g@puNv>9BZFFnEFxc z4O)MW#PFv1t>21nrKlFc7-fMSi0?!A-$JPK2wuxj@McB=kTjx##xtg$VP?ChAVTda zCfkF6z%A{bWzlf?{KD)1gbu#ZoM-&hGB)<>*(krd`wV9Wa&ayvhkE}Hqqo(+yR*LU z;JYjSwp?W~9c6zfzd8RMJAra+wftLm09|z4T+!zLTfED7G?94J=!yfWZH0MU4$J*( zbTXphHJ?B!^MtsiLLc-E{~hMUBGZ4nq4ejGneUbV96p|QobdKRb-&kX%W)~4sX*7Q)$wL9mZN8G4(YEk|GNkF#0 zX(J9C?anRy^G-Fy5xAFf zj0U`zRN$W=kA9o@10SXwC0g^8auz1TqBrB?!Vgd@2hx`cVSh&6Am+zG!n{ABLOZW9 zPs9+2zd?MDc~}4Ehvj^N5t)abJpWTO-uOOd&|7GHvyivSH;f4CL6)gXpRc?24preD zyz3eqC+_iIS$b@C_SjPDK7Bfr%SHEy9A~&Le^OBpaa1F5PWR3pyX9?5F$2*xciy`0 zy(B|<D=PFUh zRP|yesohWc@(7TAhyKU$dHqybTsG@Nc>`GLF9Fs>;^c zIo)$<-C1vHohW+F-gm7%Z}Wz0i>3a)jax3zY#2AMIIyoa-r0Wt?gK}5e&pD~FNbqw zR}n-v>T0GUn!MmD3Cj_oX6d?Qvq~!4HMrQ*isOX=UUZcii*ZEJ&?V7fl*Rqc_4CVk zeiJ14R&yS8k)u1MsSo3`v)cH`KxK0rJdJ^Cp5 zcF*gW>A84p&22}<#*W;!X5*?A!!l-6<;7cfM4Ej1gOpDn!e8mWH^`^cL@?-4Knl;v zdwFufhDLwXl`~5Kzlb>7%Lhhp6knbD16;eDpb zZxD*+F976u-{(#94LjZZ%ag3ZKFNwA*iK|Ofo|Yqs77@Iy?75=AK4SRCUQ?CLNLqf znh8RZRGVuMQ3({;Kl1l#fPhEh%{09h21ER0mTt^+Ue#zoRXi7evWm!$$>CBGv~U$U zfc}*vksK~1!3$TZmZgG$bL1?kR#f#9YSc48| z1kaiVR44utDFjosX_`OXFx4NXSZcwz4x-j)cslrAYv%gHPN1C$UP5;Kz~u+V=>cAu z?_Dk>B~6kZl;Ze*e3g`z$9pF87ZclSqnUO0#s6_u_^a_S=lFq%-u&L>G=un%i2qU| zeroyN{A6$3(rnFIc+dEOv9SZk_m7Y7|5PKd^q?p{7}P-W_K>`)UAHxT4&9IE>riAg zvNCc>pv5Ojf#55}pHGbqRGGb@Rb3rQsXE-y2TlRD=nMvkg@K7GB7W(V{(5a_T^b!J z(DoBtY_uexk^JA&kPrTSAt_aMj7z9i{SsoWl_!J}A+`?5Ie6vi)BawX`}dLHVK{bn zCgtp6zIpmIc(wTi0Jwk^I1@36D{aFo5GBuY!};BLLXXiBWaylRuQb{Q=(A^MA=MRP zOtX;r)Ajw~Uc7cKifo8%j6589KOWgm!VPehX_m490pkFvtq7#RCPkix$f#8LfNQDt z6Rf{_xG6dfd6vMR5IUA(LxkGJdVudxRqupk%QPIJppkVgqy{lT5;}^&3KDpo7lbdW z3M0yp<^G2%sJyIaB!(e@gWh@%9uFR{3?J7yS>$?4-IXc{geRp_@#y-Qfp)id;(!>9 ze-a@7kz6GqBF2o5WZUypE0@ToM-)Uf{ygvE(gjVAsRWEme*^O%O-}~MFO&8NkR-!$K0qa z=o-WKbhOoUMb8<_4v#XbrWic)x%%GqF0`J?VTLF_q5+R8nkEAcKf%1kF9!TXizOug ziyBbD3MmoOPVePd%U03XzNVp^>Pd{~|AkuC{Fg*V^3+?o_er9_8qz=hQfkntr_jF0 z6h#!nuaG0LP6=!|vBDU1DdSk11cPptrMN?IB$y||A&bd-blD0_LqhkIG~lb<0DMX? zOhGe$uQLK8NsJ%^KB<_7V3kxPaFBc7!A=qwnN{?Zz%Y1Jr2O8)AIF0uF>1*Gv8eLE zd7H9OTDooLCD9l#{8xaE_5h%8it%%um6c>tk(6J@MFj#wM^el-7Rg&nlE4B(;{;AL zMn7}#Ey`*`jB2cIY?IAhW(=Th8y-@H@k};m?#QYO|4xP=h+p?V&_{yzkRbxu08uiqjR`gKE@(#P^xV*dfHxa}g2ZXd z#^eKD-z#3F<*v%p#Xea2EAVQr>~yzTiqm1G^N#Zqt3!5GhPRVRn-L9Jv6edyr@C@S zS&hPaDzJG6RY^=6-|M3I~tdre-XIn={+nraqx__;n>C(Xs zdZuJ_>i(znY^M%3=z7`Jzeaw+2Q=$AES^jq6-72l>5>v{?^crz&)X^~Up~!MoUV3D zb~>UN+`gY$9eB2}Zd-*DO-Y8|lxG(oJoJl02bavqDBcx^zf5$+-@4GJUbuGs)9cq> zIH3C{^sbB!Zp6pm>xTa+qZ8k4)I0Tm)4#5FW{t1m9~yWbKaJEPYl##UuU(;}I+Rg1 z^x91u?R?-lN-Vx8UQeA6Y0@TOiK2{vXYy0M@p$i4-rw7^wmVjg^%ucoWY_msjD!dy zCluv`QUtrgxY4IEw|6M~X$|@Tz8eXiCna^S?Eys^^yP=75K`1g_#7?+m*YPkI-{Oe z)kA`ViC9)xQ>Q)eK%bBFM0Q7Rq~5!vpLqX+wuVCXW3WkacGg3-;~B+|G;Fl8&~2Je zaV?<&sfAWxrsV}0bYKT&S6MJH7+PeF^oAnG0%WDMEzNYSy6Mp9$f29=UaHD+TpOO) zxNK<~{{w>FtiKs=T&WV+9rgmSbhs*sbxZ>cw=K)As0`#0mmp1QzGP z$u0zvuuBvWLfS5sC4P~Nx?3e{0ThMZf`}mcI4OH(^x!Rb-+Xu!B)LRFW*t)PhnFv{ zw$G5ZosiPf`&pimS#6)jiK4*KcIdlxR$+J^w*ps<_ZKfFh$5%$#cKz%1VBODB}mOW zAw4kwqM%J@*xbY-ZB*mCGjeg{A-tzjWY$6}a%>%#75)D-#<7aLMHzI%MRxy0KTwf9I<1I$4AoLvWj1i!wIU~ogVSG|35Xw`Q2}tKD75o z+(@a#KDg2w;5N5{M$+gWcnyKi;}s#%-DmytKU;nH0)N_%`Fo4~uXt15EBzF-XyFsF zc852<`Avg&+GB7NPTJg8T=>MdZ2MbqQ?aiFM`)62)|(QBFeGN zGp{g@Nf7t*8?N!J8HcWU{_rOb93qdCat;MPK@GQ|>n?v`hIBCwz+yOPerbJlX%;$h}wLi8Ox2Zc$m~f)ZLP-2O~rkAFzjXIVbYKsQ`(Pd&5xN z>zdHp&#S7au$)tf6{3n0SF5qEqElIMV5K8TuHkt|sq~kfsA=21LyOv$Z(nK&lKrOe zx?6C$J0xD?SY3}9s-=lYVy$#rvOJp0ruAGb&uF@yEIPcvs_IC0c{H0%Dk(40piwWO zc5OWk+l;`0&}s$a8mUpb7|#z_ca?r|bl;^UOweU}N58$H9!6+G?~V%dVz}Xcn@MFz zoFTYkbl+&08|)<69wqrRDdyV(%MyX_i%exlFD;}M6U?TTs^?^XZR)LuZWx{CUucjKt9?Agl5^o5a=KqjmkVGuT7#&=|S(XBd*#VAI{SRoG zC4oc!5B>MSVmvAcqUES@i-cWLoZSquV!{K)+L&`B1>uKPZXoObP_Ycrf1k#38^J}O z=)dp3M`b%{TOt5a$xhjlB!qK<^rIRXulM52ew?c(R@e!?a7HHt9y4x@Za@}^ukFbH zK1T;=&Sn=#F|X|e9JOfkrF!ed#&`A;ypAPkq~2<%G54sQHBX>Nh}&und7kEDtQ z-#W!4qTs6}WQIQ;O`<8po3@C0Bw3bz_mXIvJbH`&_fZeFWBRlmdQt!HC0m6IxPdjD zC=1@nLL!~2kHrJ{+xl+g?{E`>y(;ueXrZdq8V1QNR_f8S zij@|Myv1)K4CndUA8G?MA~6XzTukt!4LevC9A`xs@#E5JW&pbut zlQBUNZ5zO-JU1T%3{WHW=-~CYTo1N();o6zy2IJi zmup}cymro#DHw8&)V`xRA2Hieea-Y7A@w!yY6F9Op`VHpsc?i8Ks9JGd6U?r3PDJ=Y)l+pLIusRj zl@tY0P?V;}Emh~jl30aRs}NR#lJIgQ;*un}QIdclDM>Mes0E0BktTH(MOR*e7o-H< z0c(N)83qc%3aKnhOJr9pbjlje2i<;IKJx*NEdgF=c_P7b!8659i9Ca3d3jGAa2NCJ zwrmLIfbN(aWC7F!jWhZ+Uei2Rl~}o86{C6FYQ^<*l{b2IUKKS6Sjd@1ZO-W`y+Mq z;9$Rav@Sa(Of{Ku)S+Us4%Iwd8L16IKxC(2nFdtdY@(>717xi}*$&6wU10~EYEa@xyPZj&yIteekE-2Jr zSod_XZ{eA~Vn-|inGVes47|S!>5Fw%nCkI zYedRvG9rgpF3GP-wRu6@@0X4P1MlB>kH&-g_Ka*uc$NWpd`p{ZvB4DM#q8LMqv0Tm zx|D*4H>EmawAlJ^Y&q!z$4)dogd1Kmx6vttBeCY=i)jE0)SOpJ1x z-YnEQryf9^TJew%or;sG1;t>#8h{cKF)dd(!YiKqPDO+q1Q*Q2ONm%j3B-#^HI^vF zXZ#NbsUE|mDk)KlOR4BE*gYIgxh4t%s0+O4;6(rPMO}?6@m`!y2a9BRrBC51z!%pL z=>B^Z@d2gOj`Zs#5jD&I@mZDjWa1ssR4O_ajZ3t6IjWEa{wniy=^1W&n*VE)V??*EfBc=MrFjA_6X7N9SLixw}`IE$&d@ck% z`3wO)S?h42%Gjp9ewN0HE<}bX=47a24q?%W&%@bqpuuaxNf(K9%5DVt z86Fyemz|^nw0BayuB-nmbu^)ohQNo+;DSVZ{#g+9_(>{@yCDufMDw4Zw@;Gaf^gF` zJP~*-IMXCYf3o57hD$pe4|WpwW&N9x2M|lS(7|h@2Lu5CYW3(sUI5=@ztO--{$C53 zw9FUZ`OrHHf|SYf@$-+KKb7sw;=jLk$uavz1qKWMQMi}ke=(|4!L@` zLz=e+NZM=q{uRcFsUY=H60qf|jmV8_DEWmtM0NlZwf-E=sAAY3K*Q2Vi=C8k$iC;flEOPy_FhTB8Rc6L7ZHPym6xC$Qpt%^3OrT{cQd zOJ)Se;{@5xr>dFSMAvx6@Qi}1iY)JV0>kQwOs;*fw|~6D@b?W5Z9o6W=C!LXNpev2 z9F}!=PAy)3-r6;t-QAsQ)}FU(PL6Awy!$Oj&fh*Xe2&rl8$-_XaWg4KMPiL(CX%sq zG&)@F9$>O0Tb8qBO|(T(WRaY3yo9bTYU>~5@sBSb*@??6F%!8 zYoA)Xx~sd7=iE3yMjBZE{vN{XZHUWkg?@iz&G265`UHH%@;qxHijOXQCF*%mdTq5_ z!-u_!>sGuMy^80NQ`y6RVSP8Cxkx6mA#!#4WL62;xAg-&81MU^iqiS7U`~s^TjZ#p6qBIlW&sqHn zUct;4;0?xE;xGg@g5g;4cDN3znwixq{@SvZH8mA8j$-N<0K8Q6%$Qn01vO@RMN;#z zq~!d0XuG^-btt`Ey&BgAl!Cf?4QU8YSrhJyKSOumx7mkB_#lyQSAtOP!3cyO2Im#1 z{lgRtb(KUvjlVfWg!x#3A4DBvRUr=Q|&$oZ%%O7dy^I4fMTz=0Z z_gr2;@Wm04H6`Wl{CMFjxcqx0K?g`RG7`Ld;bsM~2t5%uzY>j|WElp@VQlc9QNM&f zy!YM^xiRvt$h+U{$OSW*=yCt=%;XwhLvLm*gtA&yI)q-H$Ci^%JOkR zW`jzXIIND#WjO}V^tDLSjGRhmdd|=^1RW{$Hd?1=#FadQ8`3h<(bJs&`TE-Q6ZEb~ zEYeFc^23oXhA$v|GXM?M-vrqY=_*csMJ6BK^-{=B>XaCaEq)d7-@{Q8>!W#)4kn*X zs*GFEI(qO(YnCcQHIX{WXY_v)hZMQa%fNC80|l!%0{j+EQ)`9aj`2G zl1M7-KY~Y1Q;V`Y2`*cBt5g$!E=c#_LY~L}-=&HsE0FjsO2e*lI#9_$TyNu1K+cLQ7r`gR&d3+XFM2KRfHJtWY48d45)1$^H<<&r!qD);12v*bBIBB zHgXbDbe7L&1c4QGId1n<6PB!tOO~?`*7UAu?PkzRT zB`ngs56Y(`gjWhlw>S>bz#Uec)dfATv#MvsC0*yzmZu&DpIC0ors=Qj%?FXqaoO;@ zicXrj@MpAOm|Pueb2mOX5P%a zH}mc%y{F68tta`AELoOy*phromasv_Hdsbret@yf2H6h5u|FVz4>gwvM|M`7=8*f!Z;58jye%LISNf9Ug@Bt<3VexdE#-VEIE)AfC`5vtCk_1e zmZ)%~ti_0nGNzS9$qi$XL*z@c2oZv!TzZJ-@bltpdX3-qz5erD$a}mtw2NXqUNS3< z=V}DFlo$mG>^$|^H7T|sH3Uu3*jXyNligy8??#ja37SFfAeQ>DnsW(1{j2*do833I zD~lC+R$&$y*7qyd;BRWYwy!oWh1uQJI?F5y9C83!x&O#HS>U#_VfAS>%x=dL|2a9X z9hQeswgOjp=Casmjh`G`>nC%SY)~=ju%HlPudX0%j?_JG7W;^f5iyXV7@S#&; zT>XNqs`3}qxOhs~edRmfx#TDP5c(MLq2ncvB+1SOoP;1q39WYb$^W|{;GheCNz@uh z5)8o0U%*qSh?4w3;!k*%QTSJKt77XUUI?q-RcvMl>-Y}4owb$kD`A0`K89D^Q6z?a z`HNVbWsY$`k0`gN8tU~()p$_mYd+;OhA zR*#~`@GLYzu)F&x9PTe8{2pe+#5Mo=oraK>Btk(FL1oUAB8p*{G22GW*07{nV z&GF#4!b^w5M1&I?*M91lz?TP}x_<9fyXdcN3TG$eea#V3e9zIt$4>rX1OHmR^5Atx zSE=O4o|G{^pSj$@;=azPVotLd#mgD%)5dhh@bMzMz7kdx##YC4$(B;~=|e~r7!=>eQI&+hhkl(NH_-5<)Z6Do_TXvfu zln0+AZJeOZUUl^Faon=HB)YfiOaLsp#uJv6r64^MoW)PmF z*gR(v19(dRXDt#DKdma)fGn=SQU6=8q`d7_T=YQ&yd_w2mh5<6ML)!{>tUL+GTKex z0R+Z|F47tZ5?uT0hRT$Ut`xMD#6m=34g(SZ zQZv4SqWl-IPQ5FMV|e!5VaYPJ^EZa8$+6^-Uq z8x7vhYuDYpV)n+HUcc$a*%i%^t9IP{UPEUXd2h;23`8Z-RSc|W4JOAjk|IQQ-WBIr z<8$D)@{Gd8HIajM*|VMOge9qpVM5V$->(?)dpNNitxjhqa_CPa9dUbjM`X}$9!Z?o zvJY?AeD$gaTI1|A^rUth^_dF2=)- ztz+HRaw0>qNKzBs9SNIdxJ*MW&-`UF0 z?c|LS(^+>tX?iYoxR5TT-Q6>e1+3mxz%P#hoeex%=vRFaaF3yIJp8#=F*&yo+_EOZBsbdi~5xrD!eaXT^ZbFYo6RC$0R`J1*|$_q~a3To%h0548z` zKq2TUb99;GFVRg{&eLYq*|+SGX9w7|yAy>Q3PU=txhFU2Mwx@xd~ zvh(aXiE$i1M?45K!!H;SIKKA|{yUQ~OWupQ0PEuP}SrY=LfGzt1;8>e#R$;7U<#_)~8) zeLm&nY@G)iDW2K$y5dH1KJS}vd!~|*Sga}t#_+Mu5Acb&dPgzA8k#%uNz=+XiJy=6 zZ2ceB5W=}`@DDEWDBMz=oD`TBidhd&|0$56n25Qq-bZDZRu?LPE)e7lk}sRd z^WL)ET=unQ-r@nF+EP~!$pWkEmTC#Eh?f{!WHqeak184fj3zKLulos=lOFT&OFKw{ zH27zp`LHN)oXh}&lT3*-ke~_vi@wIg-{Ccl?|h>32_E2GaEQYy{->Tn*K{HBW$(T1lkADr%RA-J5_o6ZS$4*aR%HHz~W6cIt^~RpnikY`xC3qH>-xqu`PU z6cw4s^;Kjr?v`OOYD;)yCG47^wvQi}*9(g`AKy2@dst^guAIcjL`AsCvTaMWeJStz zdC9lYI(&6dhp(s5=~EWh`CmI0r$f($z8d=D zUJOe(K*4ld?i3iKP64Wfka6d^bwU`_9^I$jckw>! zG{Ux(dXFNkvG&Z$=gT2{5xvSmX`#e}!uhFKV)F=`a|YkcorOCV9e@0rASr zy)Vf_E{nBiENOF{V-O%y11&kMa^(m*WW`t$4;Do#mPN##wc@NrcEw^zf^6q&`}d;> zaqIe9%oXOZ;(&;y$|w+zE6N$EbAZ$V}+E!0`8*y$5>X( zxu9&M@}|PW8|*Mj#C4G`qO<@R`NG||<<|84^Ut7@~U?TF&dGfx+TZ0%|wkv{Y=NUiv8vvdnzkguUsGxO-K^Ga`&-6be^;WU4&{z}l5Fk8j71V=)oki~OaSQmQ!)2}aoB z5=E8yg6>0}N0Forcpln$m638)A#5o~5Q+&g)%nP=g}TMzsgDsDMesp(cx(<&u!4eB zE$agc3lIXV@&SvNIEBn;=-R2n4}9=}dbJJ^v{OE0AzHt3`af;k`PA&>96p=e3V-_- z-RH;9)+Z)tD{D0)()rfK4FyMrFKb;L6;#9nk+nfgw}yQ|VMT#8Pb(}V2n?&7Hd%61 z5ti&JBtB#f;Yy+EQ$UE*4m5}!yc2OeI@G;MgGYiCryvF}U>{y$1bT_CsBcNY!F9ve zR5nydx>hByD?zBeJe052T`m(=nPl zkW_U!64yMZ{xvt5bpN6rPWrWyW2?y%Y1tGsO)%y3iKJ>frJA1%H{`*1ePSe$7@4TY zJEb7}C$TU9I>mTB-o5YXl-v2k-VL7!!eF|# znops3;WcM%=%&!g9;;?ou)RdY{xWJltipGLq?aPIOEd}^OGI|=BBQpqJlb*!c=Q$8 zJsZ074pB;tBsTw`WcXGgsT)#chcD@@EVH`g@9>qf>}BQigGwZ$+zDP6BO9X@vT^y`k?0-U^D#gx_R92<1{j^}r;XE~64{UEQT zGyF|Q`Ak~nA7-5s-$J)QmfUqHe)*>_j}HY60e}nM#obxWbBBgpPs`q&QY|GSa{z3| z%PyyLx|%wQ->&OO>CDlzf(wZA5>Y=+KDGYzdNSZ_JmBUux$}&X)>Px){>@Oev;nrs z+7GvH@7aT%L~HPSA?(&8&M)c4OV4+FuF(5{kQJ3Q)lcE6b?BAF_@GZBkn#Ui{9`b_ z_{tDIkn&YsjBZVcDkF-FD#k(Qx%!$q{zu?26;rZBJZqDv<2t$KG^wUe0+F#GBHEHA z=Z+S&u$fMqVXb&)j$BypJd(U-$cio=$M8xa7&mYiD2On`veE@!?Ex4Y+@j|Oy=jI8K z`n)tXarN#SZzHFZ>uy`Su(I>RWWA<+ksSV_R;wrb_JITF`A`nG_cD^TmL!TDL6W^F zx0>k6w2Ay1AUOt2?>!3i+6RZyQo;sOW?;BcU%cXLS1i^Wm7!m|#)&(*t!oy_0|VuS zH6QK$*s3|Erep>)ie@_KktREs&q%;dNa@C%EB2FXNhSODY};~BXlmi`J1@AJ{umGI zkIAL7@0UyRV|uv%e9xgrLv|=e;>tT3Lb+n$zo#G)h^Tn{TR!HC0yp z`mWx2h7^1Pi?@+%|0rb1-_A4S1n_l7znYxCNXmX@Mo!U-@yRer_m>WzBBkVacRc(9 z`iD?7v^_u%Bks%~S)RE37|J%=M%@_(Pes+P%?N+9-GG34;PT2A`{|U$?k7TUX~{>P z3*-fdMn{h|sYac{69D|junng7jjulbo)Z~}HFA!HaOKSl|9ah3J66tS^MD;ZGO=Ub zn!&-CP%5%Kqm)WsD&mK+fTTtgjX@&6b*Mrt#o&};?o&)t*#`{I0N0b)%NS%{QS@-cRFwJK*C6n%TxMo+{l*=cRIxlbuy);!`DuUzJm}@Tt8y$c_32UD ziMj=DYRlrjW7qE;pFID^Ca%;ogW9_{GW#~Vm(hnqjnMAU?N}ClkYbi*`+|Ni<0PO6 zPr)8S%xC~!*PGUH5oWy9>G+%=^@0gx?J!UILCTw^Wj?1p<-Bm%tkyfoC=A z01=E1%!mrdCe&O+IlwTIq7aD_9a17WH35)h6%1SN3~Ta%r4rlI^Uo9W?Qbo+R=?HC z$WQmiu|04WJ$PdM-ub+(Jt}N2*3M2G|WK$qlNHLC% zf;i|*7d)gu(^cRXO_>oN=hSqX8~9F{Po*{fagLWvEvc`6r1R`K20<(dvL9D)XYK9$ z3&ke|-d^0R@W-MfIGwi}DOEMDzur*QlmYJ5bVuY*Jon9~S(cFm{J)RmQE!mK3WLLp>ilxSVnl*#cqL=N_du9Ig*ev+_vD4D9lQS57-Ky#v`S* z=a#)w6pbce0*<`aqa%cC9sOtHdnVxMHRAwLlgH1p<5`$&X8<(&m@(I2x$8jaUaS}Q zGTM_g>@o;pptE)m!r5BNclKIc>sBw#t+^yt_C?En;&WdQz|xT2jfkLAmfJ(JBOwp` zYjH=hHBmDYmnAc%D@$u?Ud&7UKPnH&oZiBn?}Uy|gYtWNJ<} zVMe+72(QN^3oyFiDQs#iRV`FkRaR8WDLW}7WJ}ZPFWNeS5^rc&FNT>?EasRNrO+WJL!V&mfESXA5|qL zrkBKZhxBACQ5zVV$Sh=yeS;BIhKONpv*eBIquF-BbHNLu5-~h4gM~a3iJZu%GJG=Z z3`O&Y8t1Wids(=rNaG@Ru-5+{1 z^zqQ|5KjliAq?ku{2AimF-EYo@l;3Fps{wV!T!SIqYXBQ3kXQ++snO`b~nwB22ylu z(Lqp44Ft8!f#5E7l42)&BweHma~a$_M3%~B2;(f}5ad|mtt1h}=7((tuH*T2kg=hL zB|P#QA)8g;_m>?}mwv0AJCcp$+@9HFBT*#~`PXi)R5u^){JCbD8W`3rvoq{7a2L<- zf{YJMw6ufFEA1!?z7`*371g#tVYYDoZ{a&xgQ+At2j#>Z2l>KWL3#aK2CL;ntYy6z zw9D?d3k5Ctn~Pf&{AR21MyfVDTT3V`50 zP=8b&r;-X$3kca!#^mwmO|-Xe1Q>M)x*nju<*p!uXBC@b1`%5&79pv}5xx7wZcfFT zVda$?~yRyD&~ymN9lZ}9@FfNZ+Kv+AznB$M;}I}{+vc{tU1c@M~SHr@qtcgdNE zau=tHwjvn3u6174f*ariiUGkLQaAv935PY`lEft+@rQB$xS>(XTz<(17K8Zj3%iWy zfdmO?mgI&JlZX?hkryOQs&x`inaiGzH}OpLZ08W%e{3hOaJ>%Nc~Z$Mng;Jy^0Eg0 z2Yy`N)-C!+5;@Sh#KSxbeyy|V_)ZFKQ%p5DR@-?}mT@)b?@<&r3qz7<8*$$jMN&_^ zZ^=JrHJb*YE%-l@?^uFz`-XKA4|&sb zNILM)+21vhKQi#*FfMPWt7YNfrbx3D|C$L(0s&4}C~g(_G^IzqI0 zuQ?Xj(o3_jxfB@L%Tr!vAoj#%VtKCM{k&N$FyDKZHq3y1#ZGBq%R*_RvU1Pp?BN+( z5AwbN8YhCtd5dhpJnlKjsu9_VRbbr* zekW3#EiaTx3u<#qW#;hgYP^t*M9c2X4KtpmO%M?U{s@bUYUj(H@0^*oJvpLq5UiMo ztU{~a-!H>*_Z+8@dbLQeic++;^exBu%@^l^DVx@Fh{g6WQsu3xBG!gD~jRIdW6A=B$i z+%KVFmgM&-y^*5MLp%*cl^nP%uKkLf%giCDrOI7u=bTBYo$K45zL2|_pGLokhna=% z!$V4iKO)!T7qIF(eUWG8tEsN24B(y%Xo$@j)8KrxaxD>H7P>O>$ zYF--Qq3f0Ff5vv8@??t_h$dh)oN>#+$E8Jl;T}tpqkll!_eW{XHdXu{As2i#U{C+E zq4D`@@X@6LcffWklqUWmYjb+ zuNgO_mnK&baCe3-p*@u zvdJsBu&e*{bcBzgZ-$gmnpl38Az6VwTQlvuG>r%Q+u0jtXK#4hjWaVhc21O64Zwrt z)#c>)_P2IEf7gz75=t{S(t~)@d3az|8J{wdnJrIFmS;1e6uAQUX3+ON*se&>}u)Ck<)hm!lb8_!vQsd}WA}51m`l_W9wVhqgCP z%+_mqa@A!QhH$y*0fj<+x+NF?!`j~0@vEfo6KjHh9AIG)Z5JJ(r_G;g$lQdGA7*RC zvNDi%FAN5|Y*kXP)#>eD@`{y`ONK|4jEr4m)0ybjM&YPJ+|SO32uI~3AulwDW%8#& ze+jrHFJ`%$nZ`6=(Z!*WUc`dhjs2~G zHk}EBd-ThR%_#8m)F?zZ%|zu%;6#q6+9Q@2AQ1&Ux6q>T?D#^$k=ev5tqwhh&y{$} zpf!hDMXW3ka?R2hm+hXNLPSsS2)CveCLt9;oID}HrG(cy%{CYVyN+yHF_^YQUN=S) z;2X(?8Pm=5(A?%HSx4zK6o(bqXnWm{*0rPfLpl*1-}Wxwa9~7p4^CIZctvBw4;YSP zJm6O}gMa;8oI{G_s)My~t>#1(Dd$+C;&TiqDu`tzYawadzNK@Mg zYH}KDrlO4$HElI)K4y3$MbREh`FxaN!H;>g(#Pu_RS8OODaGI-ME&~P~&c7PI( zD@aIZ@-}o@7kR|b?h+BtG#Uu< zNF3%wW)^W!Wd(=fdh^1sqmPBOP(HLKw52~6?@8yT-JFIbuS z;3ENQMxi@lUNAd@2SL0(UXO!BZM>Gap?O84amB$an$0UZpUbtjes60lr!7%O)Udm5HCIv?Oa^ zl#6(QupMZrAolltVR)-IGc+{gZ4Fl25|)!^-LhYgbmio_Uv_7~U#PdhQ#}Gkjw%+| zH`S}CFV`>C-EYB?e)25(5Y@v;ymzQT*1SI1&vH2iOQ>9FWCCEZj&vEG!-?X_75d#2 zpe+TxgbIp*M123i{)Sc8%>hQLq!UX zSB`DGccoWwZG-tLBcw>BVzL9pzCS_+jw=a0IxzNe^#{ z4ODV?A|+WseLLv0s1h0`nuC3j@4wkv{&*J*lPW0SQ}H5130{k(N!r3*D>lZ;UM2c- zMNR=eslJ;&lmehP5Zlr}lX~F$=l1#nPqkw1Gm3NcoCYG{&aGh|=%mSkef`Jihxpr^ zq-gcGUw}@$9HyjCK@k9(kWj;mBMt2!(hw5XmrF$&c2{V(AK8V~Ch+Pl*Y0kOkGFPT zdrLZPB{|nBmJjZ}`aQ)`sra6&cONVlEtgAL>8~mUeMOB&QrLI-;+W3;jh)2$G}Jxc z7N8^{H?DI^b%hl1G$cu5MY+(q%8tcsS5jkzn3*2*(0j>kUMBZrp_@-3j^5%==-oJ! z%Da%&bWW~aP{~h`Yj4`5+9EO{%8C_svh!OPHwAntT8yb;=i7EPYJ;*nm^Qn9vd^OL z(Ydb}+KT1CV-(?m%-ub{t7SW0w%E_Y;t&}eV}X;Ju;`dYti<;rvr4&QPf;kLt`mWN zLc9~0E%08Gl7|#?8F(5C+gU%-c|7?0XyDs8oAbiN4J8u2whI znBBA1SY~9xiN_xH}fl-tDQ-FCx8b0!MbK|LnF<6XENU$h?XKl zoRkw+MRN>j=D=6;&7PhFccFn$IFup$YF*xDtY((_1iCJ}1im%e3Xm?kQezjT0cHgw zo>m`sqrUruQ|sL2V+A^!1?PYw22+;ki-bm1RFR+=9o>lQ!nJB8`ah$?+8$MTYcBH7 zksRH0{z2esmEjRq6zyia5f*|u4*sa$#tr}8R(kbp5GTF_0)mU*eA|_Sl8IM#5)sd_ ztYVqmNH#kn{mM_YO}4{)Ztvb4Z<-3rawGTMHzM`sjAP55^ZNhTC-vwwLjuufLI2cx z_W!#B=0&kGGExzN9TVG=lPyWp{$u)NQHo2IV{boRkz%&kdho*!w#B|~A8^Ea6#MQ% zp4dX)Pf0%Lpx3B4aR~z1PAq^iD>xa;OC~)l<6Ouo>lK;>{O`MV%0Q?TIUXrNWTY(8 z1eLafa9C!szSY7q*xJyh&=sM*p&LWDP{f7@h*U=BcbZ*5)9k2kYZ7$Pb&2@J=2~qm zVY)Ovf-DHQwR{W9saR3Pe{mU~YvGlzNq*rnTvB9d1QCAWT)Rk>swQXC!!{RO5?tPH z68{=0Ku?&F%9(A`Z=Kq9rkc;^27diurKYCUS|z{w_Hwb@dHfrIZR~Ec2jj7@GEg0; zD&g3_H0L#OW?Gp?aPu}6{2rG~@V^JxO}s2kPKoLQP~+{GEmyWw6}QqH9_7F{K;_EG z=^5#U=DaFQO-gGRcJn53<>vsqb&Jo##|H}Q_Irlm9$44Ddw%!!eea1Y3{qu(=gjy! zR$W!CjX+of31)d)Ir z<-<=cFfUQ2KJ@2IF&4S($XnlXO>=RHS7N)P*NAU>kTNs}mlHSkMbA-x6VKrVj}W-q z-%PV(w8@_g^7GzAThH~J@VI6F4`D;C-v9src${NkWME(b;@9t;C&csHd}ZKfegPC= zcqZtP2&4c1{r`_+HS;GRmxF-`Bnkk5cMdQBc${NkWME)^!2kp-$N&HRZOgHmfsp|P zFopsEhlK_9c${NkU|?W=K`Q?L7lO&s`=300MB*+QnoV!NK{?cwADP*8t2$N4x+4000000P+D^0jL5t0+a(113m;61g-@R z1)2sL2DAtO2x17p31$im3Wf^q3nmL#3y=&%47Lq|4fqb~51bHG5WEpg5ug#=5~dRh z6F?J;6YLa}6#5mY791AJ7a|vY7$g{$8B!U18X6jK8qymi8*&@;92gv69EcqN9f%$5 z9$X&E9|RxTAaEegAs8WQA_^i-BF-adBoZW^B=#kMCFmwnCafoPC>SVYD9|ZfDaR_3LDoV( zLZ(92LsmpAM6yLNMPNmOMg~TVM*c@kN2EvgNN`A!Nf=2|NtQ|aN<>O*O8QG&OQuWQ zOjb;gOzKTUO}I`TPS{TrPk2wHPs~toQ20@5QU+3-Q$ACMQ|44!REkvgRbW;6R%lk# zS6WyISmIeoS%_KITEtrBTOeDSTgF@NT$o)3U4&iGUeaFrUqD}CUy5J0U*cd0U^-xC zV4PsYVH9CTVWeT$VlrY_VuWI(V+dntW2|I&WZGqJWsGIyW~OI6XX5*i;j!tj8=@8jQ)*YjslL_k2sHzkO+`Ukra_wl30@HlUkF^lx~#Dm8h0BmRy#U zmm-*;nHZURnjD&pn&_LBoIIS4odTVfo!XwXp6s7WpRS+&c${NkWME(nW#nTJU;qIo zAm#!>28RD&J_7(0MFETec${61y-ve06opUvhiDK2Au*e!3sU+s_5s?N4%yShNn0gx z8^8m85TIKDhQ4;Iha0L3_epoc1M;Kr5Fwt0 zM^rDuW4vM_9O4OYoDd9d>5P#H_i&F|xKICGctHLT9&-Ap@QD7G@E9?^g+ok&(LObG zyV1q5Osr>LVr|>KsntB1dA-)Pwu#f3+MZQ=Di+S=Dz~OmD^ojNmPR$!9CYfUopa4{ zI?ct8Qd41%6o!9pv4KW`BT6K&SN|IG!0ZTbx;2oH%Dg6jLf2+X~Q||mc5{IX|r)QZNygLlb%uL6i*h=DyV|gVzJ(+_&W@ct)W@ct)W@f%C$#zfozRa7p z{;yPIsr0K6*ai1qk$$`XrqHJHedmZSi(juV-pU*fj9^U z;}9H*!*Do`z>zo#N8=bAi{o%SPQZyc2`A$eoC*)8;dE@q7Hq{fY{w4l#4hZ{9-M(Q zaTd5nha!;H7vOUXEAbm3S3ijo09{cpYAkH{gwU6W)xs;H`KY-i~+Rop=}C zjrZWacpu)658#9N5I&5L;G_5$K8{b|llT-qjnCk-_#8fuFW`ɲSD;H&r=zK(C; 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 `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 }, + }, + }, +});