feat: 1-to-1 message render + web data-lake backend
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""media unique_id for edit detection
|
||||
|
||||
Revision ID: a9c3e7f1d2b4
|
||||
Revises: f7a2c9e1b3d5
|
||||
Create Date: 2026-05-30 04:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a9c3e7f1d2b4"
|
||||
down_revision: str | None = "f7a2c9e1b3d5"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("media", sa.Column("unique_id", sa.String(), nullable=True))
|
||||
op.add_column("media_versions", sa.Column("unique_id", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("media_versions", "unique_id")
|
||||
op.drop_column("media", "unique_id")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""media versions (keep every edited media)
|
||||
|
||||
Revision ID: f7a2c9e1b3d5
|
||||
Revises: a3f1c8e94d72
|
||||
Create Date: 2026-05-30 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f7a2c9e1b3d5"
|
||||
down_revision: str | None = "a3f1c8e94d72"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"media_versions",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||
sa.Column("chat_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column(
|
||||
"observed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.String(), nullable=False),
|
||||
sa.Column("storage_key", sa.String(), nullable=False),
|
||||
sa.Column("file_size", sa.BigInteger(), nullable=True),
|
||||
sa.Column("mime", sa.String(), nullable=True),
|
||||
sa.Column("ttl_seconds", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
"chat_id",
|
||||
"message_id",
|
||||
"storage_key",
|
||||
name="uq_media_versions_content",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_media_versions_message",
|
||||
"media_versions",
|
||||
["account_id", "chat_id", "message_id", "observed_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_media_versions_message", table_name="media_versions")
|
||||
op.drop_table("media_versions")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ from userbot import PyroClient
|
||||
from userbot.modules.capture import repository
|
||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||
from userbot.modules.capture.message import sender_id
|
||||
from userbot.modules.media import self_destruct_ttl
|
||||
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
|
||||
|
||||
|
||||
@PyroClient.on_edited_message()
|
||||
@@ -18,7 +18,7 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
||||
toggles = ctx.resolve(meta)
|
||||
if not toggles.track_edits_deletes:
|
||||
return
|
||||
await repository.add_version(
|
||||
changed = await repository.add_version(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
chat_id,
|
||||
@@ -28,9 +28,12 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
||||
message.text or message.caption,
|
||||
str(message),
|
||||
message.edit_date,
|
||||
media_unique_id(message),
|
||||
has_media=message.media is not None,
|
||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||
)
|
||||
if changed and message.media is not None:
|
||||
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
||||
|
||||
|
||||
handlers = on_edited_message.handlers
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from userbot.modules.avatars.downloader import capture_avatar
|
||||
from userbot.modules.avatars.repository import note_avatar
|
||||
|
||||
__all__ = ["capture_avatar"]
|
||||
__all__ = ["capture_avatar", "note_avatar"]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import asyncpg
|
||||
|
||||
_INSERT_AVATAR = """
|
||||
@@ -13,6 +15,16 @@ SELECT 1 FROM avatars
|
||||
WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
|
||||
"""
|
||||
|
||||
_GET_FILE = """
|
||||
SELECT raw ->> 'file_id' AS file_id, downloaded FROM avatars
|
||||
WHERE account_id = $1 AND owner_kind = $2 AND owner_id = $3 AND unique_id = $4
|
||||
"""
|
||||
|
||||
_MARK_DOWNLOADED = """
|
||||
UPDATE avatars SET downloaded = true, storage_key = $5, file_size = $6
|
||||
WHERE account_id = $1 AND owner_kind = $2 AND owner_id = $3 AND unique_id = $4
|
||||
"""
|
||||
|
||||
|
||||
async def avatar_exists(
|
||||
pool: asyncpg.Pool, account_id: int, owner_id: int, unique_id: str
|
||||
@@ -46,3 +58,54 @@ async def insert_avatar( # noqa: PLR0913
|
||||
downloaded,
|
||||
raw,
|
||||
)
|
||||
|
||||
|
||||
async def note_avatar( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
owner_id: int,
|
||||
owner_kind: str,
|
||||
unique_id: str,
|
||||
file_id: str,
|
||||
) -> None:
|
||||
await insert_avatar(
|
||||
pool,
|
||||
account_id,
|
||||
owner_id,
|
||||
owner_kind,
|
||||
unique_id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
json.dumps({"file_id": file_id}),
|
||||
downloaded=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_avatar_file(
|
||||
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int, unique_id: str
|
||||
) -> tuple[str | None, bool] | None:
|
||||
row = await pool.fetchrow(_GET_FILE, account_id, owner_kind, owner_id, unique_id)
|
||||
if row is None:
|
||||
return None
|
||||
return row["file_id"], row["downloaded"]
|
||||
|
||||
|
||||
async def mark_avatar_downloaded( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
owner_kind: str,
|
||||
owner_id: int,
|
||||
unique_id: str,
|
||||
storage_key: str,
|
||||
file_size: int,
|
||||
) -> None:
|
||||
await pool.execute(
|
||||
_MARK_DOWNLOADED,
|
||||
account_id,
|
||||
owner_kind,
|
||||
owner_id,
|
||||
unique_id,
|
||||
storage_key,
|
||||
file_size,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncpg
|
||||
from pyrogram import Client
|
||||
|
||||
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
|
||||
from userbot.modules.contacts import ContactCache
|
||||
from userbot.modules.folders import FolderCache
|
||||
from userbot.modules.watches import WatchCache
|
||||
@@ -25,6 +26,8 @@ class CaptureContext:
|
||||
self.folders = folders
|
||||
self.contacts = contacts
|
||||
self.watches = WatchCache(pool, account_id)
|
||||
self.peer_identity = PeerIdentityCache()
|
||||
self.chat_meta = ChatMetaCache()
|
||||
self.policies = PolicySet()
|
||||
|
||||
async def reload_policies(self) -> None:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from userbot.modules.capture.chat_meta import chat_kind
|
||||
from userbot.modules.profiles.parse import ProfileFields, snapshot_from_high_level
|
||||
from userbot.modules.profiles.repository import get_peer, write_profile
|
||||
from utils.policy.models import ChatKind
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
from pyrogram.types import Message
|
||||
|
||||
from userbot.modules.capture.context import CaptureContext
|
||||
|
||||
|
||||
class PeerIdentityCache:
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[int, ProfileFields | None] = {}
|
||||
|
||||
async def changed(
|
||||
self, pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields
|
||||
) -> bool:
|
||||
if peer_id not in self._cache:
|
||||
self._cache[peer_id] = await get_peer(pool, account_id, peer_id)
|
||||
if self._cache[peer_id] == fields:
|
||||
return False
|
||||
self._cache[peer_id] = fields
|
||||
return True
|
||||
|
||||
|
||||
class ChatMetaCache:
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[int, tuple[str | None, str | None]] = {}
|
||||
|
||||
async def changed(
|
||||
self,
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
chat_id: int,
|
||||
meta: tuple[str | None, str | None],
|
||||
) -> bool:
|
||||
from userbot.modules.groups.repository import ( # noqa: PLC0415
|
||||
get_latest_chat_meta,
|
||||
)
|
||||
|
||||
if chat_id not in self._cache:
|
||||
self._cache[chat_id] = await get_latest_chat_meta(pool, account_id, chat_id)
|
||||
if self._cache[chat_id] == meta:
|
||||
return False
|
||||
self._cache[chat_id] = meta
|
||||
return True
|
||||
|
||||
|
||||
async def _capture_peer(message: Message, ctx: CaptureContext) -> None:
|
||||
user = message.from_user
|
||||
if user is None:
|
||||
return
|
||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
|
||||
return
|
||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user))
|
||||
if photo_file_id and photo_unique_id:
|
||||
from userbot.modules.avatars import note_avatar # noqa: PLC0415
|
||||
|
||||
await note_avatar(
|
||||
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
|
||||
)
|
||||
|
||||
|
||||
async def _capture_chat(message: Message, ctx: CaptureContext) -> None:
|
||||
chat = message.chat
|
||||
if (
|
||||
chat is None
|
||||
or chat.id is None
|
||||
or message.date is None
|
||||
or chat_kind(chat.type) is ChatKind.DM
|
||||
):
|
||||
return
|
||||
photo = chat.photo
|
||||
photo_unique_id = photo.big_photo_unique_id if photo else None
|
||||
photo_file_id = photo.big_file_id if photo else None
|
||||
meta = (chat.title, photo_unique_id)
|
||||
if not await ctx.chat_meta.changed(ctx.pool, ctx.account_id, chat.id, meta):
|
||||
return
|
||||
from userbot.modules.groups.repository import insert_chat_history # noqa: PLC0415
|
||||
|
||||
await insert_chat_history(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
chat.id,
|
||||
message.id,
|
||||
"meta",
|
||||
chat.title,
|
||||
photo_unique_id,
|
||||
None,
|
||||
message.date,
|
||||
str(message),
|
||||
)
|
||||
if photo_file_id and photo_unique_id:
|
||||
from userbot.modules.avatars import note_avatar # noqa: PLC0415
|
||||
|
||||
await note_avatar(
|
||||
ctx.pool, ctx.account_id, chat.id, "chat", photo_unique_id, photo_file_id
|
||||
)
|
||||
|
||||
|
||||
async def capture_identity(message: Message, ctx: CaptureContext) -> None:
|
||||
await _capture_peer(message, ctx)
|
||||
await _capture_chat(message, ctx)
|
||||
@@ -51,6 +51,9 @@ async def capture_message(
|
||||
has_media=message.media is not None,
|
||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||
)
|
||||
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
|
||||
|
||||
await capture_identity(message, ctx)
|
||||
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
||||
buttons = callbacks(message)
|
||||
if buttons:
|
||||
|
||||
@@ -23,28 +23,71 @@ INSERT INTO messages
|
||||
has_media, is_self_destruct, edited_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
|
||||
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
||||
text = EXCLUDED.text,
|
||||
raw = EXCLUDED.raw,
|
||||
has_media = EXCLUDED.has_media,
|
||||
is_self_destruct = EXCLUDED.is_self_destruct,
|
||||
edited_at = now()
|
||||
"""
|
||||
|
||||
_INSERT_VERSION = """
|
||||
INSERT INTO message_versions
|
||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||
VALUES ($1, $2, $3, now(), $4, $5, $6::jsonb)
|
||||
VALUES ($1, $2, $3, clock_timestamp(), $4, $5, $6::jsonb)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
_SNAPSHOT_ORIGINAL = """
|
||||
INSERT INTO message_versions
|
||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
|
||||
FROM messages m
|
||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM message_versions v
|
||||
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
|
||||
AND v.message_id = m.message_id
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
_CURRENT_CONTENT = """
|
||||
SELECT
|
||||
m.text AS text,
|
||||
(SELECT d.unique_id FROM media d
|
||||
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
|
||||
AND d.message_id = m.message_id) AS media_unique_id
|
||||
FROM messages m
|
||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
||||
ORDER BY m.date DESC LIMIT 1
|
||||
"""
|
||||
|
||||
_CURRENT_MEDIA = """
|
||||
SELECT unique_id, storage_key, file_size, downloaded FROM media
|
||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
|
||||
"""
|
||||
|
||||
_INSERT_MEDIA_VERSION = """
|
||||
INSERT INTO media_versions
|
||||
(account_id, chat_id, message_id, observed_at, kind, storage_key,
|
||||
file_size, mime, ttl_seconds, unique_id)
|
||||
VALUES ($1, $2, $3, clock_timestamp(), $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (account_id, chat_id, message_id, storage_key) DO NOTHING
|
||||
"""
|
||||
|
||||
_INSERT_MEDIA = """
|
||||
INSERT INTO media
|
||||
(account_id, chat_id, message_id, kind, storage_key, file_size, mime,
|
||||
ttl_seconds, downloaded)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ttl_seconds, downloaded, unique_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
|
||||
kind = EXCLUDED.kind,
|
||||
storage_key = EXCLUDED.storage_key,
|
||||
file_size = EXCLUDED.file_size,
|
||||
mime = EXCLUDED.mime,
|
||||
ttl_seconds = EXCLUDED.ttl_seconds,
|
||||
downloaded = EXCLUDED.downloaded
|
||||
downloaded = EXCLUDED.downloaded,
|
||||
unique_id = EXCLUDED.unique_id
|
||||
"""
|
||||
|
||||
|
||||
@@ -111,11 +154,20 @@ async def add_version( # noqa: PLR0913
|
||||
text: str | None,
|
||||
raw: str,
|
||||
edit_date: datetime | None,
|
||||
media_unique_id: str | None,
|
||||
*,
|
||||
has_media: bool,
|
||||
is_self_destruct: bool,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
current = await conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id)
|
||||
text_changed = current is None or current["text"] != text
|
||||
media_changed = (
|
||||
current is not None and current["media_unique_id"] != media_unique_id
|
||||
)
|
||||
if not (text_changed or media_changed):
|
||||
return False
|
||||
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id)
|
||||
await conn.execute(
|
||||
_TOUCH_EDITED,
|
||||
account_id,
|
||||
@@ -131,6 +183,13 @@ async def add_version( # noqa: PLR0913
|
||||
await conn.execute(
|
||||
_INSERT_VERSION, account_id, chat_id, message_id, edit_date, text, raw
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def current_media(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||
) -> asyncpg.Record | None:
|
||||
return await pool.fetchrow(_CURRENT_MEDIA, account_id, chat_id, message_id)
|
||||
|
||||
|
||||
async def insert_media( # noqa: PLR0913
|
||||
@@ -143,21 +202,37 @@ async def insert_media( # noqa: PLR0913
|
||||
file_size: int | None,
|
||||
mime: str | None,
|
||||
ttl_seconds: int | None,
|
||||
unique_id: str | None,
|
||||
*,
|
||||
downloaded: bool,
|
||||
) -> None:
|
||||
await pool.execute(
|
||||
_INSERT_MEDIA,
|
||||
account_id,
|
||||
chat_id,
|
||||
message_id,
|
||||
kind,
|
||||
storage_key,
|
||||
file_size,
|
||||
mime,
|
||||
ttl_seconds,
|
||||
downloaded,
|
||||
)
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
await conn.execute(
|
||||
_INSERT_MEDIA,
|
||||
account_id,
|
||||
chat_id,
|
||||
message_id,
|
||||
kind,
|
||||
storage_key,
|
||||
file_size,
|
||||
mime,
|
||||
ttl_seconds,
|
||||
downloaded,
|
||||
unique_id,
|
||||
)
|
||||
if storage_key is not None:
|
||||
await conn.execute(
|
||||
_INSERT_MEDIA_VERSION,
|
||||
account_id,
|
||||
chat_id,
|
||||
message_id,
|
||||
kind,
|
||||
storage_key,
|
||||
file_size,
|
||||
mime,
|
||||
ttl_seconds,
|
||||
unique_id,
|
||||
)
|
||||
|
||||
|
||||
async def insert_callbacks(
|
||||
|
||||
@@ -17,6 +17,26 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
ON CONFLICT (account_id, chat_id, message_id, user_id) DO NOTHING
|
||||
"""
|
||||
|
||||
_LATEST_TITLE = """
|
||||
SELECT title FROM chat_history
|
||||
WHERE account_id = $1 AND chat_id = $2 AND title IS NOT NULL
|
||||
ORDER BY ts DESC LIMIT 1
|
||||
"""
|
||||
|
||||
_LATEST_PHOTO = """
|
||||
SELECT photo_unique_id FROM chat_history
|
||||
WHERE account_id = $1 AND chat_id = $2 AND photo_unique_id IS NOT NULL
|
||||
ORDER BY ts DESC LIMIT 1
|
||||
"""
|
||||
|
||||
|
||||
async def get_latest_chat_meta(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||
) -> tuple[str | None, str | None]:
|
||||
title = await pool.fetchval(_LATEST_TITLE, account_id, chat_id)
|
||||
photo_unique_id = await pool.fetchval(_LATEST_PHOTO, account_id, chat_id)
|
||||
return title, photo_unique_id
|
||||
|
||||
|
||||
async def insert_chat_history( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
from userbot.modules.jobs.handlers import backfill, fetch_media, transcribe
|
||||
from userbot.modules.jobs.handlers import (
|
||||
backfill,
|
||||
enrich_chat,
|
||||
fetch_avatar,
|
||||
fetch_media,
|
||||
transcribe,
|
||||
)
|
||||
|
||||
__all__ = ["backfill", "fetch_media", "transcribe"]
|
||||
__all__ = ["backfill", "enrich_chat", "fetch_avatar", "fetch_media", "transcribe"]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pyrogram import Client
|
||||
from pyrogram.errors import BadRequest, Forbidden
|
||||
from pyrogram.types import User
|
||||
|
||||
from userbot.modules.avatars import note_avatar
|
||||
from userbot.modules.capture.context import CaptureContext
|
||||
from userbot.modules.groups.repository import insert_chat_history
|
||||
from userbot.modules.jobs.context import JobContext
|
||||
from userbot.modules.jobs.registry import register
|
||||
from userbot.modules.profiles.parse import snapshot_from_high_level
|
||||
from userbot.modules.profiles.repository import write_profile
|
||||
|
||||
MEMBER_CAP = 200
|
||||
|
||||
_MISSING_SENDERS = """
|
||||
SELECT DISTINCT sender_id FROM messages
|
||||
WHERE account_id = $1 AND chat_id = $2 AND sender_id > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM peers p WHERE p.account_id = $1 AND p.peer_id = messages.sender_id
|
||||
)
|
||||
LIMIT 100
|
||||
"""
|
||||
|
||||
|
||||
async def _save_user(ctx: CaptureContext, user: User) -> None:
|
||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user))
|
||||
if photo_file_id and photo_unique_id:
|
||||
await note_avatar(
|
||||
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
|
||||
)
|
||||
|
||||
|
||||
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
|
||||
chat = await client.get_chat(chat_id)
|
||||
photo = chat.photo
|
||||
photo_unique_id = photo.big_photo_unique_id if photo else None
|
||||
photo_file_id = photo.big_file_id if photo else None
|
||||
await insert_chat_history(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
chat_id,
|
||||
0,
|
||||
"meta",
|
||||
chat.title,
|
||||
photo_unique_id,
|
||||
None,
|
||||
datetime.now(UTC),
|
||||
str(chat),
|
||||
)
|
||||
if photo_file_id and photo_unique_id:
|
||||
await note_avatar(
|
||||
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
|
||||
)
|
||||
|
||||
|
||||
async def _enrich_members(client: Client, ctx: CaptureContext, chat_id: int) -> None:
|
||||
try:
|
||||
async for member in client.get_chat_members(chat_id, limit=MEMBER_CAP):
|
||||
if isinstance(member.user, User):
|
||||
await _save_user(ctx, member.user)
|
||||
except (BadRequest, Forbidden):
|
||||
return
|
||||
|
||||
|
||||
async def _enrich_senders(client: Client, ctx: CaptureContext, chat_id: int) -> None:
|
||||
rows = await ctx.pool.fetch(_MISSING_SENDERS, ctx.account_id, chat_id)
|
||||
ids = [row["sender_id"] for row in rows]
|
||||
for sender_id in ids:
|
||||
try:
|
||||
user = await client.get_users(sender_id)
|
||||
except BadRequest:
|
||||
continue
|
||||
if isinstance(user, User):
|
||||
await _save_user(ctx, user)
|
||||
|
||||
|
||||
@register("enrich_chat")
|
||||
async def enrich_chat(ctx: JobContext) -> None:
|
||||
client = ctx.client
|
||||
if client is None:
|
||||
return
|
||||
capture = getattr(client, "capture", None)
|
||||
if capture is None:
|
||||
return
|
||||
chat_id = ctx.job.params["chat_id"]
|
||||
await _enrich_chat_meta(client, capture, chat_id)
|
||||
await _enrich_members(client, capture, chat_id)
|
||||
await _enrich_senders(client, capture, chat_id)
|
||||
@@ -0,0 +1,40 @@
|
||||
from io import BytesIO
|
||||
|
||||
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
|
||||
from userbot.modules.jobs.context import JobContext
|
||||
from userbot.modules.jobs.registry import register
|
||||
|
||||
|
||||
@register("fetch_avatar")
|
||||
async def fetch_avatar(ctx: JobContext) -> None:
|
||||
client = ctx.client
|
||||
if client is None:
|
||||
return
|
||||
capture = getattr(client, "capture", None)
|
||||
if capture is None:
|
||||
return
|
||||
owner_kind = ctx.job.params["owner_kind"]
|
||||
owner_id = ctx.job.params["owner_id"]
|
||||
unique_id = ctx.job.params["unique_id"]
|
||||
found = await get_avatar_file(
|
||||
ctx.pool, ctx.account_id, owner_kind, owner_id, unique_id
|
||||
)
|
||||
if found is None:
|
||||
return
|
||||
file_id, downloaded = found
|
||||
if downloaded or file_id is None:
|
||||
return
|
||||
buffer = await client.download_media(file_id, in_memory=True)
|
||||
if not isinstance(buffer, BytesIO):
|
||||
return
|
||||
data = buffer.getvalue()
|
||||
storage_key = capture.storage.put(data)
|
||||
await mark_avatar_downloaded(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
owner_kind,
|
||||
owner_id,
|
||||
unique_id,
|
||||
storage_key,
|
||||
len(data),
|
||||
)
|
||||
@@ -1,3 +1,7 @@
|
||||
from userbot.modules.media.downloader import capture_media, self_destruct_ttl
|
||||
from userbot.modules.media.downloader import (
|
||||
capture_media,
|
||||
media_unique_id,
|
||||
self_destruct_ttl,
|
||||
)
|
||||
|
||||
__all__ = ["capture_media", "self_destruct_ttl"]
|
||||
__all__ = ["capture_media", "media_unique_id", "self_destruct_ttl"]
|
||||
|
||||
@@ -33,6 +33,11 @@ def self_destruct_ttl(message: Message) -> int | None:
|
||||
return getattr(obj, "ttl_seconds", None) if obj is not None else None
|
||||
|
||||
|
||||
def media_unique_id(message: Message) -> str | None:
|
||||
_, obj = media_object(message)
|
||||
return getattr(obj, "file_unique_id", None) if obj is not None else None
|
||||
|
||||
|
||||
async def capture_media( # noqa: PLR0913
|
||||
client: Client,
|
||||
message: Message,
|
||||
@@ -44,6 +49,7 @@ async def capture_media( # noqa: PLR0913
|
||||
kind, obj = media_object(message)
|
||||
if obj is None:
|
||||
return
|
||||
unique_id = getattr(obj, "file_unique_id", None)
|
||||
ttl = getattr(obj, "ttl_seconds", None)
|
||||
want = toggles.self_destruct_media if ttl else toggles.media
|
||||
file_size = getattr(obj, "file_size", None)
|
||||
@@ -51,12 +57,25 @@ async def capture_media( # noqa: PLR0913
|
||||
storage_key: str | None = None
|
||||
downloaded = False
|
||||
if want:
|
||||
buffer = await client.download_media(message, in_memory=True)
|
||||
if isinstance(buffer, BytesIO):
|
||||
data = buffer.getvalue()
|
||||
storage_key = ctx.storage.put(data)
|
||||
file_size = len(data)
|
||||
existing = await repository.current_media(
|
||||
ctx.pool, ctx.account_id, chat_id, message_id
|
||||
)
|
||||
if (
|
||||
existing is not None
|
||||
and existing["downloaded"]
|
||||
and existing["unique_id"] == unique_id
|
||||
and existing["storage_key"] is not None
|
||||
):
|
||||
storage_key = existing["storage_key"]
|
||||
file_size = existing["file_size"]
|
||||
downloaded = True
|
||||
else:
|
||||
buffer = await client.download_media(message, in_memory=True)
|
||||
if isinstance(buffer, BytesIO):
|
||||
data = buffer.getvalue()
|
||||
storage_key = ctx.storage.put(data)
|
||||
file_size = len(data)
|
||||
downloaded = True
|
||||
await repository.insert_media(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
@@ -67,5 +86,6 @@ async def capture_media( # noqa: PLR0913
|
||||
file_size,
|
||||
mime,
|
||||
ttl,
|
||||
unique_id,
|
||||
downloaded=downloaded,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from userbot.modules.profiles.parse import (
|
||||
ProfileFields,
|
||||
active_username,
|
||||
snapshot_from_high_level,
|
||||
snapshot_from_user,
|
||||
)
|
||||
|
||||
__all__ = ["ProfileFields", "active_username", "snapshot_from_user"]
|
||||
__all__ = [
|
||||
"ProfileFields",
|
||||
"active_username",
|
||||
"snapshot_from_high_level",
|
||||
"snapshot_from_user",
|
||||
]
|
||||
|
||||
@@ -37,3 +37,20 @@ def snapshot_from_user(
|
||||
is_deleted_account=bool(getattr(raw_user, "deleted", False)),
|
||||
)
|
||||
return fields, photo_file_id, photo_unique_id
|
||||
|
||||
|
||||
def snapshot_from_high_level(
|
||||
user: User,
|
||||
) -> tuple[ProfileFields, str | None, str | None]:
|
||||
photo = user.photo
|
||||
photo_unique_id = photo.big_photo_unique_id if photo else None
|
||||
photo_file_id = photo.big_file_id if photo else None
|
||||
fields = ProfileFields(
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
username=user.username,
|
||||
phone=user.phone_number,
|
||||
photo_unique_id=photo_unique_id,
|
||||
is_deleted_account=bool(user.is_deleted),
|
||||
)
|
||||
return fields, photo_file_id, photo_unique_id
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -6,3 +6,7 @@ services:
|
||||
api:
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
|
||||
frontend-dev:
|
||||
ports:
|
||||
- "127.0.0.1:5173:5173"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUndeclaredVariables": "off",
|
||||
"noUnusedFunctionParameters": "off",
|
||||
"noUnusedVariables": "off"
|
||||
},
|
||||
"style": {
|
||||
@@ -52,6 +53,17 @@
|
||||
"includes": ["src/app.html"],
|
||||
"linter": { "enabled": false },
|
||||
"formatter": { "enabled": false }
|
||||
},
|
||||
{
|
||||
"includes": ["src/lib/styles/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noDuplicateProperties": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": { "enabled": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+80
-2
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<script>
|
||||
(() => {
|
||||
const stored = localStorage.getItem("bg.theme");
|
||||
const dark =
|
||||
stored === "dark" ||
|
||||
((stored === "system" || stored === null) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("theme-dark");
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const WAVE_DURATION = 700;
|
||||
|
||||
export function ripple(node: HTMLElement) {
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const rect = node.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
let container = node.querySelector<HTMLElement>(".ripple-container");
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.className = "ripple-container";
|
||||
node.append(container);
|
||||
}
|
||||
const wave = document.createElement("div");
|
||||
wave.className = "ripple-wave";
|
||||
wave.style.width = `${size}px`;
|
||||
wave.style.height = `${size}px`;
|
||||
wave.style.left = `${event.clientX - rect.left - size / 2}px`;
|
||||
wave.style.top = `${event.clientY - rect.top - size / 2}px`;
|
||||
container.append(wave);
|
||||
setTimeout(() => wave.remove(), WAVE_DURATION);
|
||||
}
|
||||
|
||||
node.addEventListener("pointerdown", onPointerDown);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("pointerdown", onPointerDown);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const RETRY_DELAY = 2500;
|
||||
|
||||
export type AvatarKind = "peer" | "chat";
|
||||
|
||||
const ready = new Map<string, string>();
|
||||
const missing = new Set<string>();
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
function cacheKey(account: number, kind: AvatarKind, id: number): string {
|
||||
return `${account}:${kind}:${id}`;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAvatar(
|
||||
account: number,
|
||||
kind: AvatarKind,
|
||||
id: number,
|
||||
key: string,
|
||||
retry: boolean
|
||||
): Promise<string | null> {
|
||||
const url = `${BASE}/avatars/${kind}/${id}?account_id=${account}`;
|
||||
const response = await fetch(url, { headers: authHeaders() });
|
||||
if (response.ok) {
|
||||
const objectUrl = URL.createObjectURL(await response.blob());
|
||||
ready.set(key, objectUrl);
|
||||
return objectUrl;
|
||||
}
|
||||
if (response.status === 409 && retry) {
|
||||
await delay(RETRY_DELAY);
|
||||
return fetchAvatar(account, kind, id, key, false);
|
||||
}
|
||||
missing.add(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadAvatar(
|
||||
kind: AvatarKind,
|
||||
id: number
|
||||
): Promise<string | null> {
|
||||
const account = accounts.selectedId;
|
||||
if (account === null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const key = cacheKey(account, kind, id);
|
||||
const cached = ready.get(key);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
if (missing.has(key)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const existing = inflight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const promise = fetchAvatar(account, kind, id, key, true).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const MAX_LIMIT = 500;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
|
||||
constructor(status: number, detail: string) {
|
||||
super(detail);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
interface RequestOptions {
|
||||
account?: boolean;
|
||||
body?: unknown;
|
||||
method?: string;
|
||||
query?: Record<string, QueryValue>;
|
||||
}
|
||||
|
||||
function buildQuery(
|
||||
query: Record<string, QueryValue> | undefined,
|
||||
withAccount: boolean
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (withAccount && accounts.selectedId !== null) {
|
||||
params.set("account_id", String(accounts.selectedId));
|
||||
}
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const clamped =
|
||||
key === "limit" && typeof value === "number"
|
||||
? Math.min(value, MAX_LIMIT)
|
||||
: value;
|
||||
params.set(key, String(clamped));
|
||||
}
|
||||
}
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
|
||||
}
|
||||
|
||||
async function handleError(response: Response): Promise<never> {
|
||||
if (response.status === 401) {
|
||||
auth.logout();
|
||||
await goto("/login");
|
||||
}
|
||||
let detail = response.statusText;
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (data && typeof data.detail === "string") {
|
||||
detail = data.detail;
|
||||
}
|
||||
} catch {
|
||||
detail = response.statusText;
|
||||
}
|
||||
throw new ApiError(response.status, detail);
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = "GET", query, body, account = false } = options;
|
||||
const headers: Record<string, string> = authHeaders();
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const response = await fetch(`${BASE}${path}${buildQuery(query, account)}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return handleError(response);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export type MediaResult =
|
||||
| { state: "ready"; url: string; mime: string | null }
|
||||
| { state: "not-downloaded" }
|
||||
| { state: "missing" };
|
||||
|
||||
export async function requestMedia(mediaId: number): Promise<MediaResult> {
|
||||
const response = await fetch(`${BASE}/media/${mediaId}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
return { state: "not-downloaded" };
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return { state: "missing" };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return handleError(response);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
state: "ready",
|
||||
url: URL.createObjectURL(blob),
|
||||
mime: response.headers.get("Content-Type"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestMediaVersion(
|
||||
versionId: number
|
||||
): Promise<MediaResult> {
|
||||
const response = await fetch(`${BASE}/media/version/${versionId}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return { state: "missing" };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return handleError(response);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
state: "ready",
|
||||
url: URL.createObjectURL(blob),
|
||||
mime: response.headers.get("Content-Type"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { request } from "$lib/api/client";
|
||||
import type {
|
||||
Account,
|
||||
Chat,
|
||||
Folder,
|
||||
JobView,
|
||||
MediaVersion,
|
||||
MediaView,
|
||||
MessageVersion,
|
||||
MessageView,
|
||||
PeerView,
|
||||
} from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
interface Page {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export function listAccounts(): Promise<Account[]> {
|
||||
return request<Account[]>("/accounts");
|
||||
}
|
||||
|
||||
export function listChats(page: Page = {}): Promise<Chat[]> {
|
||||
return request<Chat[]>("/chats", { account: true, query: { ...page } });
|
||||
}
|
||||
|
||||
export function listFolders(): Promise<Folder[]> {
|
||||
return request<Folder[]>("/folders", { account: true });
|
||||
}
|
||||
|
||||
export function listMessages(
|
||||
chatId: number,
|
||||
options: Page & { include_deleted?: boolean } = {}
|
||||
): Promise<MessageView[]> {
|
||||
return request<MessageView[]>(`/chats/${chatId}/messages`, {
|
||||
account: true,
|
||||
query: { ...options },
|
||||
});
|
||||
}
|
||||
|
||||
export function listMessageVersions(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): Promise<MessageVersion[]> {
|
||||
return request<MessageVersion[]>(
|
||||
`/chats/${chatId}/messages/${messageId}/versions`,
|
||||
{ account: true }
|
||||
);
|
||||
}
|
||||
|
||||
export function listDeleted(
|
||||
options: Page & { chat_id?: number } = {}
|
||||
): Promise<MessageView[]> {
|
||||
return request<MessageView[]>("/deleted", {
|
||||
account: true,
|
||||
query: { ...options },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPeer(peerId: number): Promise<PeerView> {
|
||||
return request<PeerView>(`/peers/${peerId}`, { account: true });
|
||||
}
|
||||
|
||||
export function getPeers(ids: number[]): Promise<PeerView[]> {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return request<PeerView[]>("/peers/batch", {
|
||||
account: true,
|
||||
query: { ids: ids.join(",") },
|
||||
});
|
||||
}
|
||||
|
||||
export function enrichChat(chatId: number): Promise<{ job_id: number }> {
|
||||
return request<{ job_id: number }>(`/chats/${chatId}/enrich`, {
|
||||
method: "POST",
|
||||
body: { account_id: accounts.selectedId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getJob(jobId: number): Promise<JobView> {
|
||||
return request<JobView>(`/jobs/${jobId}`, { account: true });
|
||||
}
|
||||
|
||||
export function getMediaVersions(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): Promise<MediaVersion[]> {
|
||||
return request<MediaVersion[]>(`/media/versions/${chatId}/${messageId}`, {
|
||||
account: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getMediaMeta(mediaId: number): Promise<MediaView> {
|
||||
return request<MediaView>(`/media/${mediaId}/meta`);
|
||||
}
|
||||
|
||||
export function getMessageMedia(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): Promise<MediaView> {
|
||||
return request<MediaView>(`/media/message/${chatId}/${messageId}`, {
|
||||
account: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMedia(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): Promise<{ job_id: number }> {
|
||||
return request<{ job_id: number }>("/media/fetch", {
|
||||
method: "POST",
|
||||
body: {
|
||||
account_id: accounts.selectedId,
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { requestMedia } from "$lib/api/client";
|
||||
import { getMessageMedia } from "$lib/api/endpoints";
|
||||
import type { MediaRef } from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
export type InlineMedia =
|
||||
| {
|
||||
state: "ready";
|
||||
mediaId: number;
|
||||
kind: string;
|
||||
mime: string | null;
|
||||
url: string;
|
||||
transcript: string | null;
|
||||
}
|
||||
| { state: "not-downloaded"; mediaId: number; kind: string }
|
||||
| { state: "missing" };
|
||||
|
||||
export interface ViewerItem {
|
||||
downloaded: boolean;
|
||||
kind: string;
|
||||
mediaId: number | null;
|
||||
messageId: number;
|
||||
}
|
||||
|
||||
export function viewerItemsFrom(
|
||||
messageId: number,
|
||||
media: MediaRef[]
|
||||
): ViewerItem[] {
|
||||
if (media.length === 0) {
|
||||
return [{ messageId, mediaId: null, kind: "", downloaded: false }];
|
||||
}
|
||||
return media.map((item) => ({
|
||||
messageId: item.message_id,
|
||||
mediaId: item.id,
|
||||
kind: item.kind,
|
||||
downloaded: item.downloaded,
|
||||
}));
|
||||
}
|
||||
|
||||
export type VisualKind = "image" | "video" | "other";
|
||||
|
||||
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
|
||||
|
||||
export function visualKind(kind: string): VisualKind {
|
||||
if (kind === "photo") {
|
||||
return "image";
|
||||
}
|
||||
if (VIDEO_KINDS.has(kind)) {
|
||||
return "video";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
const ready = new Map<string, InlineMedia>();
|
||||
const inflight = new Map<string, Promise<InlineMedia>>();
|
||||
|
||||
function cacheKey(account: number, chatId: number, messageId: number): string {
|
||||
return `${account}:${chatId}:${messageId}`;
|
||||
}
|
||||
|
||||
async function resolve(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): Promise<InlineMedia> {
|
||||
let meta: Awaited<ReturnType<typeof getMessageMedia>>;
|
||||
try {
|
||||
meta = await getMessageMedia(chatId, messageId);
|
||||
} catch {
|
||||
return { state: "missing" };
|
||||
}
|
||||
if (!meta.downloaded) {
|
||||
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind };
|
||||
}
|
||||
const blob = await requestMedia(meta.id);
|
||||
if (blob.state === "ready") {
|
||||
return {
|
||||
state: "ready",
|
||||
mediaId: meta.id,
|
||||
kind: meta.kind,
|
||||
mime: blob.mime,
|
||||
url: blob.url,
|
||||
transcript: meta.extracted_text,
|
||||
};
|
||||
}
|
||||
if (blob.state === "not-downloaded") {
|
||||
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind };
|
||||
}
|
||||
return { state: "missing" };
|
||||
}
|
||||
|
||||
const byId = new Map<string, InlineMedia>();
|
||||
const byIdInflight = new Map<string, Promise<InlineMedia>>();
|
||||
|
||||
async function resolveById(media: MediaRef): Promise<InlineMedia> {
|
||||
if (media.id === null) {
|
||||
return { state: "missing" };
|
||||
}
|
||||
if (!media.downloaded) {
|
||||
return { state: "not-downloaded", mediaId: media.id, kind: media.kind };
|
||||
}
|
||||
const blob = await requestMedia(media.id);
|
||||
if (blob.state === "ready") {
|
||||
return {
|
||||
state: "ready",
|
||||
mediaId: media.id,
|
||||
kind: media.kind,
|
||||
mime: blob.mime,
|
||||
url: blob.url,
|
||||
transcript: null,
|
||||
};
|
||||
}
|
||||
if (blob.state === "not-downloaded") {
|
||||
return { state: "not-downloaded", mediaId: media.id, kind: media.kind };
|
||||
}
|
||||
return { state: "missing" };
|
||||
}
|
||||
|
||||
export function loadMediaItem(media: MediaRef): Promise<InlineMedia> {
|
||||
const account = accounts.selectedId;
|
||||
if (account === null || media.id === null) {
|
||||
return Promise.resolve<InlineMedia>({ state: "missing" });
|
||||
}
|
||||
const key = `${account}:${media.id}`;
|
||||
const cached = byId.get(key);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
const existing = byIdInflight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const promise = resolveById(media)
|
||||
.then((result) => {
|
||||
if (result.state === "ready") {
|
||||
byId.set(key, result);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
byIdInflight.delete(key);
|
||||
});
|
||||
byIdInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function loadInlineMedia(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): Promise<InlineMedia> {
|
||||
const account = accounts.selectedId;
|
||||
if (account === null) {
|
||||
return Promise.resolve<InlineMedia>({ state: "missing" });
|
||||
}
|
||||
const key = cacheKey(account, chatId, messageId);
|
||||
const cached = ready.get(key);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
const existing = inflight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const promise = resolve(chatId, messageId)
|
||||
.then((result) => {
|
||||
if (result.state === "ready") {
|
||||
ready.set(key, result);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
export type ChatKind = "private" | "group";
|
||||
export type PolicyScopeType =
|
||||
| "default_dm"
|
||||
| "default_group"
|
||||
| "default_channel"
|
||||
| "folder"
|
||||
| "chat";
|
||||
export type SearchSource = "text" | "stt";
|
||||
export type JobStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "canceled"
|
||||
| "paused";
|
||||
|
||||
export interface Account {
|
||||
account_id: number;
|
||||
is_active: boolean;
|
||||
label: string | null;
|
||||
phone: string | null;
|
||||
tg_user_id: number | null;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
chat_id: number;
|
||||
has_avatar: boolean;
|
||||
is_bot: boolean;
|
||||
is_broadcast: boolean;
|
||||
is_contact: boolean;
|
||||
kind: ChatKind;
|
||||
last_date: string | null;
|
||||
last_sender_id: number | null;
|
||||
last_text: string | null;
|
||||
message_count: number;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface EntityView {
|
||||
custom_emoji_id: string | null;
|
||||
language: string | null;
|
||||
length: number;
|
||||
offset: number;
|
||||
type: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface ReplyView {
|
||||
media_kind: string | null;
|
||||
message_id: number | null;
|
||||
sender_id: number | null;
|
||||
sender_name: string | null;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
export interface ForwardView {
|
||||
chat_id: number | null;
|
||||
chat_title: string | null;
|
||||
date: string | null;
|
||||
from_id: number | null;
|
||||
from_name: string | null;
|
||||
kind: "channel" | "hidden" | "user";
|
||||
message_id: number | null;
|
||||
signature: string | null;
|
||||
}
|
||||
|
||||
export interface MediaRef {
|
||||
downloaded: boolean;
|
||||
duration: number | null;
|
||||
file_size: number | null;
|
||||
height: number | null;
|
||||
id: number | null;
|
||||
kind: string;
|
||||
message_id: number;
|
||||
mime: string | null;
|
||||
ttl_seconds: number | null;
|
||||
width: number | null;
|
||||
}
|
||||
|
||||
export interface ReactionCount {
|
||||
chosen: boolean;
|
||||
count: number;
|
||||
custom_emoji_id: string | null;
|
||||
emoji: string | null;
|
||||
}
|
||||
|
||||
export interface InlineButton {
|
||||
data: string | null;
|
||||
kind: "callback" | "other" | "switch" | "url";
|
||||
text: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface WebPageView {
|
||||
description: string | null;
|
||||
display_url: string | null;
|
||||
has_photo: boolean;
|
||||
site_name: string | null;
|
||||
title: string | null;
|
||||
type: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PollOption {
|
||||
correct: boolean | null;
|
||||
text: string;
|
||||
vote_percentage: number;
|
||||
voter_count: number;
|
||||
}
|
||||
|
||||
export interface PollView {
|
||||
anonymous: boolean;
|
||||
closed: boolean;
|
||||
multiple: boolean;
|
||||
options: PollOption[];
|
||||
question: string;
|
||||
quiz: boolean;
|
||||
total_voter_count: number;
|
||||
}
|
||||
|
||||
export interface ContactView {
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
phone_number: string | null;
|
||||
user_id: number | null;
|
||||
}
|
||||
|
||||
export interface LocationView {
|
||||
address: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceView {
|
||||
duration: number | null;
|
||||
kind: string;
|
||||
member_ids: number[] | null;
|
||||
pinned_message_id: number | null;
|
||||
}
|
||||
|
||||
export interface StickerView {
|
||||
emoji: string | null;
|
||||
height: number | null;
|
||||
is_animated: boolean;
|
||||
is_video: boolean;
|
||||
mime: string | null;
|
||||
set_name: string | null;
|
||||
width: number | null;
|
||||
}
|
||||
|
||||
export interface MessageView {
|
||||
chat_id: number;
|
||||
contact: ContactView | null;
|
||||
date: string;
|
||||
deleted_at: string | null;
|
||||
edited_at: string | null;
|
||||
entities: EntityView[];
|
||||
forward: ForwardView | null;
|
||||
has_media: boolean;
|
||||
inline_buttons: InlineButton[][];
|
||||
is_animated_emoji: boolean;
|
||||
is_self_destruct: boolean;
|
||||
is_sticker: boolean;
|
||||
location: LocationView | null;
|
||||
media: MediaRef[];
|
||||
media_group_id: string | null;
|
||||
message_id: number;
|
||||
poll: PollView | null;
|
||||
quote: string | null;
|
||||
reactions: ReactionCount[];
|
||||
reply: ReplyView | null;
|
||||
sender_id: number | null;
|
||||
service: ServiceView | null;
|
||||
sticker: StickerView | null;
|
||||
text: string | null;
|
||||
via_bot_id: number | null;
|
||||
web_page: WebPageView | null;
|
||||
}
|
||||
|
||||
export interface MessageVersion {
|
||||
edit_date: string | null;
|
||||
observed_at: string;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
export interface MediaVersion {
|
||||
file_size: number | null;
|
||||
id: number;
|
||||
kind: string;
|
||||
mime: string | null;
|
||||
observed_at: string;
|
||||
storage_key: string;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
chat_id: number;
|
||||
date: string;
|
||||
extracted_text: string | null;
|
||||
message_id: number;
|
||||
sender_id: number | null;
|
||||
source: SearchSource;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
export interface MediaView {
|
||||
account_id: number;
|
||||
chat_id: number;
|
||||
created_at: string;
|
||||
downloaded: boolean;
|
||||
extracted_text: string | null;
|
||||
file_size: number | null;
|
||||
id: number;
|
||||
kind: string;
|
||||
message_id: number;
|
||||
mime: string | null;
|
||||
storage_key: string | null;
|
||||
ttl_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface Callback {
|
||||
data: string | null;
|
||||
label: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
added_at: string;
|
||||
peer_id: number;
|
||||
reaction: string;
|
||||
removed_at: string | null;
|
||||
}
|
||||
|
||||
export interface LinkPreview {
|
||||
kind: string;
|
||||
position: number;
|
||||
url: string;
|
||||
web_description: string | null;
|
||||
web_site_name: string | null;
|
||||
web_title: string | null;
|
||||
web_url: string | null;
|
||||
}
|
||||
|
||||
export interface PresenceSample {
|
||||
last_online_date: string | null;
|
||||
next_offline_date: string | null;
|
||||
peer_id: number;
|
||||
status: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface PresenceHourly {
|
||||
bucket: string;
|
||||
last_seen: string | null;
|
||||
online_samples: number;
|
||||
peer_id: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface PeerView {
|
||||
first_name: string | null;
|
||||
has_avatar: boolean;
|
||||
is_deleted_account: boolean;
|
||||
last_name: string | null;
|
||||
peer_id: number;
|
||||
phone: string | null;
|
||||
photo_unique_id: string | null;
|
||||
updated_at: string;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export interface PeerHistoryView {
|
||||
first_name: string | null;
|
||||
is_deleted_account: boolean;
|
||||
last_name: string | null;
|
||||
observed_at: string;
|
||||
phone: string | null;
|
||||
photo_unique_id: string | null;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export interface StoryView {
|
||||
caption: string | null;
|
||||
date: string | null;
|
||||
deleted: boolean;
|
||||
downloaded: boolean;
|
||||
expire_date: string | null;
|
||||
media_kind: string | null;
|
||||
peer_id: number;
|
||||
pinned: boolean;
|
||||
storage_key: string | null;
|
||||
story_id: number;
|
||||
views: number | null;
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
account_id: number;
|
||||
chat_id: number;
|
||||
created_at: string;
|
||||
id: number;
|
||||
message_id: number;
|
||||
text: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CaptureToggles {
|
||||
backfill: boolean;
|
||||
media: boolean;
|
||||
messages: boolean;
|
||||
presence: boolean;
|
||||
profile_history: boolean;
|
||||
reactions: boolean;
|
||||
self_destruct_media: boolean;
|
||||
stories: boolean;
|
||||
stt: boolean;
|
||||
track_edits_deletes: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyRecord extends CaptureToggles {
|
||||
account_id: number | null;
|
||||
id: number;
|
||||
scope_id: number | null;
|
||||
scope_type: PolicyScopeType;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
bots: boolean;
|
||||
broadcasts: boolean;
|
||||
contacts: boolean;
|
||||
exclude_ids: number[];
|
||||
folder_id: number;
|
||||
groups: boolean;
|
||||
include_ids: number[];
|
||||
is_chatlist: boolean;
|
||||
non_contacts: boolean;
|
||||
order_index: number;
|
||||
pinned_ids: number[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Watch {
|
||||
account_id: number;
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
id: number;
|
||||
kind: string;
|
||||
params: Record<string, unknown>;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
account_id: number;
|
||||
created_at: string;
|
||||
id: number;
|
||||
payload: Record<string, unknown>;
|
||||
seen: boolean;
|
||||
ts: string;
|
||||
watch_id: number;
|
||||
}
|
||||
|
||||
export interface JobView {
|
||||
account_id: number;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
cursor: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
finished_at: string | null;
|
||||
flood_waits: number;
|
||||
id: number;
|
||||
kind: string;
|
||||
params: Record<string, unknown>;
|
||||
progress: Record<string, unknown>;
|
||||
started_at: string | null;
|
||||
status: JobStatus;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { accountName } from "$lib/format/peer";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
const current = $derived(accounts.selected);
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="account-trigger">
|
||||
{#if current}
|
||||
<Avatar
|
||||
name={accountName(current)}
|
||||
colorKey={current.account_id}
|
||||
size={2.25}
|
||||
/>
|
||||
<span class="account-name">{accountName(current)}</span>
|
||||
{:else}
|
||||
<span class="account-name">No account</span>
|
||||
{/if}
|
||||
<Icon name="down" size="1.25rem" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="bg-menu-content" sideOffset={6} align="start">
|
||||
{#each accounts.list as account (account.account_id)}
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item"
|
||||
data-selected={account.account_id === accounts.selectedId
|
||||
? ""
|
||||
: undefined}
|
||||
onSelect={() => accounts.select(account.account_id)}
|
||||
>
|
||||
<Avatar
|
||||
name={accountName(account)}
|
||||
colorKey={account.account_id}
|
||||
size={1.75}
|
||||
/>
|
||||
<span>{accountName(account)}</span>
|
||||
{#if account.account_id === accounts.selectedId}
|
||||
<Icon name="check" size="1.125rem" class="trailing" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.account-trigger) {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.account-name {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.bg-menu-item .trailing) {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { getPeer } from "$lib/api/endpoints";
|
||||
import type { PeerView } from "$lib/api/types";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import { peerName } from "$lib/format/peer";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
let { chatId }: Props = $props();
|
||||
|
||||
const isDm = $derived(chatId > 0);
|
||||
const chat = $derived(chats.byId(chatId));
|
||||
let peer = $state<PeerView | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null || !isDm) {
|
||||
peer = null;
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
peer = null;
|
||||
getPeer(chatId)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
peer = result;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
peer = null;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
|
||||
const fallbackTitle = $derived(chat?.title ?? `Chat ${chatId}`);
|
||||
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||
const subtitle = $derived.by(() => {
|
||||
if (isDm) {
|
||||
if (peer?.username) {
|
||||
return `@${peer.username}`;
|
||||
}
|
||||
return peer?.phone ?? `ID ${chatId}`;
|
||||
}
|
||||
const count = chat?.message_count ?? 0;
|
||||
return count > 0 ? `${count} messages` : "group";
|
||||
});
|
||||
const avatarKind = $derived(isDm ? "peer" : "chat");
|
||||
const hasAvatar = $derived(chat?.has_avatar ?? Boolean(peer?.has_avatar));
|
||||
</script>
|
||||
|
||||
<header class="chat-header">
|
||||
<Avatar
|
||||
name={title}
|
||||
colorKey={chatId}
|
||||
size={2.5}
|
||||
avatar={{ kind: avatarKind, id: chatId }}
|
||||
{hasAvatar}
|
||||
deleted={peer?.is_deleted_account ?? false}
|
||||
/>
|
||||
<div class="info">
|
||||
<h2 class="title">{title}</h2>
|
||||
<span class="subtitle">{subtitle}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
height: var(--header-height);
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.info {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fly } from "svelte/transition";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import ChatListItem from "$lib/components/ChatListItem.svelte";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Skeleton from "$lib/components/ui/Skeleton.svelte";
|
||||
import { folderContains } from "$lib/format/folders";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { folders } from "$lib/stores/folders.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
const skeletonRows = Array.from({ length: 9 }, (_, index) => index);
|
||||
|
||||
const activeChatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
const selectedFolder = $derived(folders.selected);
|
||||
const visibleChats = $derived(
|
||||
selectedFolder === null
|
||||
? chats.list
|
||||
: chats.list.filter((chat) => folderContains(selectedFolder, chat))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null) {
|
||||
return;
|
||||
}
|
||||
chats.load().catch(() => toasts.error("Failed to load chats"));
|
||||
folders.load().catch(() => toasts.error("Failed to load folders"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chat-list custom-scroll">
|
||||
{#if chats.loading && chats.list.length === 0}
|
||||
{#each skeletonRows as index (index)}
|
||||
<div class="row-skeleton">
|
||||
<Skeleton width="3rem" height="3rem" circle />
|
||||
<div class="row-skeleton-lines">
|
||||
<Skeleton width="55%" height="0.875rem" />
|
||||
<Skeleton width="80%" height="0.8125rem" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if chats.list.length === 0}
|
||||
<EmptyState title="No chats yet" />
|
||||
{:else}
|
||||
{#key folders.selectedId}
|
||||
<div
|
||||
class="folder-view"
|
||||
in:fly={{ x: folders.direction * 24, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
{#if visibleChats.length === 0}
|
||||
<EmptyState
|
||||
title="Empty folder"
|
||||
description="No chats match this folder yet"
|
||||
/>
|
||||
{:else}
|
||||
{#each visibleChats as chat (chat.chat_id)}
|
||||
<ChatListItem
|
||||
{chat}
|
||||
selected={chat.chat_id === activeChatId}
|
||||
onclick={() => goto(`/app/${chat.chat_id}`)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4375rem;
|
||||
}
|
||||
|
||||
.row-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
}
|
||||
|
||||
.row-skeleton-lines {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
import type { Chat } from "$lib/api/types";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import { formatListDate } from "$lib/format/datetime";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
interface Props {
|
||||
chat: Chat;
|
||||
onclick: () => void;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
let { chat, selected, onclick }: Props = $props();
|
||||
|
||||
const title = $derived(chat.title ?? `Chat ${chat.chat_id}`);
|
||||
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const showSender = $derived(
|
||||
chat.kind === "group" && chat.last_sender_id !== null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (showSender && chat.last_sender_id !== ownId) {
|
||||
peers.ensure([chat.last_sender_id as number]);
|
||||
}
|
||||
});
|
||||
|
||||
const senderPrefix = $derived.by(() => {
|
||||
if (!showSender) {
|
||||
return "";
|
||||
}
|
||||
if (chat.last_sender_id === ownId) {
|
||||
return "You: ";
|
||||
}
|
||||
const peer = peers.get(chat.last_sender_id as number);
|
||||
if (!peer) {
|
||||
return "";
|
||||
}
|
||||
return `${peer.first_name ?? peer.username ?? peer.peer_id}: `;
|
||||
});
|
||||
|
||||
const preview = $derived(
|
||||
chat.last_text ?? (chat.message_count > 0 ? "Media" : "")
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="Chat ListItem-button"
|
||||
class:selected
|
||||
use:ripple
|
||||
{onclick}
|
||||
>
|
||||
<Avatar
|
||||
name={title}
|
||||
colorKey={chat.chat_id}
|
||||
avatar={{ kind: avatarKind, id: chat.chat_id }}
|
||||
hasAvatar={chat.has_avatar}
|
||||
/>
|
||||
<div class="info">
|
||||
<div class="info-row">
|
||||
<h3 class="title">{title}</h3>
|
||||
<span class="date">{formatListDate(chat.last_date)}</span>
|
||||
</div>
|
||||
<div class="subtitle">
|
||||
<span class="last-message">
|
||||
{#if senderPrefix}
|
||||
<span class="sender">{senderPrefix}</span>
|
||||
{/if}
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.Chat {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-chat-active);
|
||||
|
||||
.title,
|
||||
.date,
|
||||
.last-message,
|
||||
.sender {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sender {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import type { EntityView } from "$lib/api/types";
|
||||
import {
|
||||
buildEntityTree,
|
||||
type EntityNode,
|
||||
type EntityTreeNode,
|
||||
jumboEmojiCount,
|
||||
linkHref,
|
||||
} from "$lib/format/entities";
|
||||
|
||||
interface Props {
|
||||
entities: EntityView[];
|
||||
own?: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
let { text, entities, own = false }: Props = $props();
|
||||
|
||||
const nodes = $derived(buildEntityTree(text, entities));
|
||||
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
|
||||
|
||||
const revealed = new SvelteSet<string>();
|
||||
|
||||
function spoilerKey(entity: EntityView): string {
|
||||
return `${entity.offset}:${entity.length}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet tree(items: EntityTreeNode[])}
|
||||
{#each items as node, i (i)}
|
||||
{#if node.kind === "text"}
|
||||
{node.text}
|
||||
{:else}
|
||||
{@render entity(node)}
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet entity(node: EntityNode)}
|
||||
{@const type = node.entity.type}
|
||||
{#if type === "bold"}
|
||||
<strong>{@render tree(node.children)}</strong>
|
||||
{:else if type === "italic"}
|
||||
<em>{@render tree(node.children)}</em>
|
||||
{:else if type === "underline"}
|
||||
<ins>{@render tree(node.children)}</ins>
|
||||
{:else if type === "strikethrough"}
|
||||
<del>{@render tree(node.children)}</del>
|
||||
{:else if type === "spoiler"}
|
||||
{@const key = spoilerKey(node.entity)}
|
||||
<button
|
||||
type="button"
|
||||
class="spoiler"
|
||||
class:revealed={revealed.has(key)}
|
||||
onclick={() => revealed.add(key)}
|
||||
>
|
||||
{@render tree(node.children)}
|
||||
</button>
|
||||
{:else if type === "code"}
|
||||
<code class="code" class:own>{@render tree(node.children)}</code>
|
||||
{:else if type === "pre"}
|
||||
<pre
|
||||
class="pre"
|
||||
class:own
|
||||
>{#if node.entity.language}<span class="pre-lang">{node.entity.language}</span>{/if}<code>{@render tree(node.children)}</code></pre>
|
||||
{:else if type === "blockquote"}
|
||||
<blockquote class="blockquote">{@render tree(node.children)}</blockquote>
|
||||
{:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"}
|
||||
<a
|
||||
class="link"
|
||||
href={linkHref(node)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{@render tree(node.children)}</a
|
||||
>
|
||||
{:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"}
|
||||
<span class="link">{@render tree(node.children)}</span>
|
||||
{:else}
|
||||
{@render tree(node.children)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<span class="EntityText" class:jumbo={jumbo > 0} class:jumbo-1={jumbo === 1}>
|
||||
{@render tree(nodes)}
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.EntityText {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.jumbo {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
&.jumbo-1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-links);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.code,
|
||||
.pre {
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-code);
|
||||
background-color: var(--color-code-bg);
|
||||
|
||||
&.own {
|
||||
color: var(--color-code-own);
|
||||
background-color: var(--color-code-own-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.code {
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.pre {
|
||||
overflow-x: auto;
|
||||
|
||||
display: block;
|
||||
margin: 0.125rem 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.pre-lang {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.blockquote {
|
||||
margin: 0.125rem 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-left: 0.1875rem solid var(--color-links);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
font: inherit;
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
|
||||
background-color: var(--color-text);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
|
||||
&.revealed {
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { folders } from "$lib/stores/folders.svelte";
|
||||
|
||||
interface Tab {
|
||||
id: number | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const tabs = $derived<Tab[]>([
|
||||
{ id: null, title: "All Chats" },
|
||||
...folders.list.map((folder) => ({
|
||||
id: folder.folder_id,
|
||||
title: folder.title,
|
||||
})),
|
||||
]);
|
||||
|
||||
const activeTab = $derived(
|
||||
Math.max(
|
||||
0,
|
||||
tabs.findIndex((tab) => tab.id === folders.selectedId)
|
||||
)
|
||||
);
|
||||
|
||||
let containerEl = $state<HTMLDivElement>();
|
||||
let indicatorEl = $state<HTMLDivElement>();
|
||||
let clipPath = $state("");
|
||||
|
||||
function updateClipPath() {
|
||||
const indicator = indicatorEl;
|
||||
const activeEl = indicator?.children[activeTab] as HTMLElement | undefined;
|
||||
if (!(indicator && activeEl) || indicator.offsetWidth === 0) {
|
||||
return;
|
||||
}
|
||||
const { offsetLeft, offsetWidth } = activeEl;
|
||||
const width = indicator.offsetWidth;
|
||||
const left = ((offsetLeft / width) * 100).toFixed(1);
|
||||
const right = (
|
||||
((width - (offsetLeft + offsetWidth)) / width) *
|
||||
100
|
||||
).toFixed(1);
|
||||
clipPath = `inset(0.25rem ${right}% 0.25rem ${left}% round var(--tab-radius))`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const index = activeTab;
|
||||
const total = tabs.length;
|
||||
updateClipPath();
|
||||
const baseEl =
|
||||
total > 0
|
||||
? (containerEl?.children[index] as HTMLElement | undefined)
|
||||
: undefined;
|
||||
baseEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!indicatorEl) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => updateClipPath());
|
||||
observer.observe(indicatorEl);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={containerEl} class="container" class:ready={clipPath !== ""}>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button type="button" class="tab" onclick={() => folders.select(tab.id)}>
|
||||
{tab.title}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<div
|
||||
bind:this={indicatorEl}
|
||||
class="active-indicator"
|
||||
style={clipPath ? `clip-path: ${clipPath}` : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button type="button" class="tab" tabindex="-1">{tab.title}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.container,
|
||||
.active-indicator {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 0.375rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
--tab-radius: 1.25rem;
|
||||
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow-x: auto;
|
||||
|
||||
border-radius: 1.5rem;
|
||||
|
||||
opacity: 0;
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
transition: opacity 150ms;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
pointer-events: none;
|
||||
will-change: clip-path;
|
||||
|
||||
isolation: isolate;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
|
||||
contain: layout style paint;
|
||||
overflow: hidden;
|
||||
|
||||
width: fit-content;
|
||||
|
||||
background-color: var(--color-primary-opacity);
|
||||
|
||||
transition: clip-path var(--slide-transition);
|
||||
}
|
||||
|
||||
.tab {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.375rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--tab-radius);
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
appearance: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.active-indicator & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { ForwardView } from "$lib/api/types";
|
||||
import { peerName } from "$lib/format/peer";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
interface Props {
|
||||
forward: ForwardView;
|
||||
}
|
||||
|
||||
let { forward }: Props = $props();
|
||||
|
||||
const peer = $derived(
|
||||
forward.from_id === null ? undefined : peers.get(forward.from_id)
|
||||
);
|
||||
const name = $derived.by(() => {
|
||||
if (forward.kind === "channel") {
|
||||
return forward.chat_title ?? "Channel";
|
||||
}
|
||||
if (forward.kind === "hidden") {
|
||||
return forward.from_name ?? "Hidden account";
|
||||
}
|
||||
if (peer) {
|
||||
return peerName(peer);
|
||||
}
|
||||
return forward.from_name ?? "Unknown";
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ForwardHeader">
|
||||
<span class="label">Forwarded from</span>
|
||||
<span class="name">{name}</span>
|
||||
{#if forward.signature}
|
||||
<span class="signature">({forward.signature})</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.ForwardHeader {
|
||||
overflow: hidden;
|
||||
|
||||
margin-bottom: 0.1875rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.signature {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
let value = $state("");
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!value.trim() || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
auth.login(value.trim());
|
||||
try {
|
||||
await accounts.load();
|
||||
await goto("/app");
|
||||
} catch {
|
||||
toasts.error("Invalid token");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login">
|
||||
<form class="login-card" onsubmit={submit}>
|
||||
<div class="logo">
|
||||
<Icon name="lock" size="2.75rem" />
|
||||
</div>
|
||||
<h1>Beavergram</h1>
|
||||
<p class="subtitle">Enter your access token to continue.</p>
|
||||
<input
|
||||
class="form-control"
|
||||
type="password"
|
||||
placeholder="Access token"
|
||||
autocomplete="current-password"
|
||||
bind:value
|
||||
>
|
||||
<Button type="submit" loading={busy} disabled={busy || !value.trim()}>
|
||||
Log in
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 2.5rem 2rem;
|
||||
border-radius: var(--border-radius-modal);
|
||||
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
background-image: linear-gradient(var(--color-primary), var(--color-primary-shade));
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 1.75rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
height: 3.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--color-borders-input);
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { MediaRef } from "$lib/api/types";
|
||||
import AlbumTile from "$lib/components/media/AlbumTile.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
media: MediaRef[];
|
||||
onopen: (index: number) => void;
|
||||
}
|
||||
|
||||
let { media, chatId, onopen }: Props = $props();
|
||||
|
||||
const columns = $derived.by(() => {
|
||||
const count = media.length;
|
||||
if (count === 2) {
|
||||
return 2;
|
||||
}
|
||||
if (count === 4) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="MediaAlbum" style:--cols={columns}>
|
||||
{#each media as item, index (item.id ?? index)}
|
||||
<AlbumTile media={item} {chatId} onopen={() => onopen(index)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.MediaAlbum {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), 1fr);
|
||||
gap: 0.125rem;
|
||||
|
||||
overflow: hidden;
|
||||
max-width: 20rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { type MediaResult, requestMediaVersion } from "$lib/api/client";
|
||||
import { visualKind } from "$lib/api/media";
|
||||
import type { MediaVersion } from "$lib/api/types";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
|
||||
interface Props {
|
||||
version: MediaVersion;
|
||||
}
|
||||
|
||||
let { version }: Props = $props();
|
||||
|
||||
let result = $state<MediaResult | null>(null);
|
||||
|
||||
const vk = $derived(visualKind(version.kind));
|
||||
|
||||
$effect(() => {
|
||||
let active = true;
|
||||
requestMediaVersion(version.id).then((value) => {
|
||||
if (active) {
|
||||
result = value;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="thumb">
|
||||
{#if result === null}
|
||||
<div class="placeholder"><Spinner /></div>
|
||||
{:else if result.state === "ready" && vk === "image"}
|
||||
<a href={result.url} target="_blank" rel="noopener">
|
||||
<img src={result.url} alt={version.kind}>
|
||||
</a>
|
||||
{:else if result.state === "ready" && vk === "video"}
|
||||
<a href={result.url} target="_blank" rel="noopener">
|
||||
<video src={result.url} muted preload="metadata"></video>
|
||||
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
|
||||
</a>
|
||||
{:else if result.state === "ready"}
|
||||
<a class="file" href={result.url} target="_blank" rel="noopener">
|
||||
<Icon name="document" size="1.125rem" />
|
||||
<span>{version.kind}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="placeholder missing">
|
||||
<Icon name="no-download" size="1.25rem" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.thumb {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
display: block;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
object-fit: cover;
|
||||
background-color: var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--color-white);
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
|
||||
padding: 0.5rem 0.625rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-default-shadow);
|
||||
|
||||
&.missing {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import { untrack } from "svelte";
|
||||
import { type MediaResult, requestMedia } from "$lib/api/client";
|
||||
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
|
||||
import type { ViewerItem } from "$lib/api/media";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
index: number;
|
||||
items: ViewerItem[];
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
index = $bindable(),
|
||||
chatId,
|
||||
items,
|
||||
}: Props = $props();
|
||||
|
||||
let kind = $state("");
|
||||
let messageId = $state<number | null>(null);
|
||||
let result = $state<MediaResult | null>(null);
|
||||
let loading = $state(false);
|
||||
let token = 0;
|
||||
|
||||
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
|
||||
const isImage = $derived(mime.startsWith("image/") || kind === "photo");
|
||||
const isVideo = $derived(
|
||||
mime.startsWith("video/") || kind === "video" || kind === "video_note"
|
||||
);
|
||||
const isAudio = $derived(
|
||||
mime.startsWith("audio/") || kind === "voice" || kind === "audio"
|
||||
);
|
||||
const hasNav = $derived(items.length > 1);
|
||||
|
||||
function revoke() {
|
||||
if (result?.state === "ready") {
|
||||
URL.revokeObjectURL(result.url);
|
||||
}
|
||||
}
|
||||
|
||||
async function load(item: ViewerItem) {
|
||||
revoke();
|
||||
loading = true;
|
||||
result = null;
|
||||
kind = item.kind;
|
||||
messageId = item.messageId;
|
||||
const current = ++token;
|
||||
try {
|
||||
let mediaId = item.mediaId;
|
||||
let downloaded = item.downloaded;
|
||||
if (mediaId === null) {
|
||||
const meta = await getMessageMedia(chatId, item.messageId);
|
||||
mediaId = meta.id;
|
||||
downloaded = meta.downloaded;
|
||||
kind = meta.kind;
|
||||
}
|
||||
const next = downloaded
|
||||
? await requestMedia(mediaId)
|
||||
: ({ state: "not-downloaded" } as MediaResult);
|
||||
if (current === token) {
|
||||
result = next;
|
||||
}
|
||||
} catch {
|
||||
if (current === token) {
|
||||
toasts.error("Failed to load media");
|
||||
}
|
||||
} finally {
|
||||
if (current === token) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queueFetch() {
|
||||
if (messageId === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetchMedia(chatId, messageId);
|
||||
toasts.success("Download queued");
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
}
|
||||
}
|
||||
|
||||
function step(delta: number) {
|
||||
const next = index + delta;
|
||||
if (next >= 0 && next < items.length) {
|
||||
index = next;
|
||||
}
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (!(open && hasNav)) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
step(-1);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
step(1);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const item = items[index];
|
||||
const isOpen = open;
|
||||
untrack(() => {
|
||||
if (isOpen && item) {
|
||||
load(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
untrack(() => {
|
||||
revoke();
|
||||
result = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="media-overlay" />
|
||||
<Dialog.Content class="media-content">
|
||||
<Dialog.Title class="media-title">{kind || "Media"}</Dialog.Title>
|
||||
<Dialog.Close class="media-close" aria-label="Close">
|
||||
<Icon name="close" size="1.5rem" />
|
||||
</Dialog.Close>
|
||||
{#if hasNav}
|
||||
<span class="media-counter">{index + 1} / {items.length}</span>
|
||||
{/if}
|
||||
<div class="media-body">
|
||||
{#if loading}
|
||||
<Spinner color="white" />
|
||||
{:else if result?.state === "ready" && isImage}
|
||||
<img class="media-image" src={result.url} alt={kind}>
|
||||
{:else if result?.state === "ready" && isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
||||
<video class="media-video" src={result.url} controls></video>
|
||||
{:else if result?.state === "ready" && isAudio}
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
||||
<audio src={result.url} controls></audio>
|
||||
{:else if result?.state === "ready"}
|
||||
<a class="media-download" href={result.url} download>
|
||||
<Icon name="download" />
|
||||
Download file
|
||||
</a>
|
||||
{:else if result?.state === "not-downloaded"}
|
||||
<div class="media-message">
|
||||
<p>This media has not been downloaded yet.</p>
|
||||
<Button variant="primary" fluid onclick={queueFetch}>
|
||||
Fetch media
|
||||
</Button>
|
||||
</div>
|
||||
{:else if result?.state === "missing"}
|
||||
<p class="media-message">Media not found.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasNav}
|
||||
<button
|
||||
class="media-nav prev"
|
||||
type="button"
|
||||
aria-label="Previous"
|
||||
disabled={index === 0}
|
||||
onclick={() => step(-1)}
|
||||
>
|
||||
<Icon name="previous" size="1.75rem" />
|
||||
</button>
|
||||
<button
|
||||
class="media-nav next"
|
||||
type="button"
|
||||
aria-label="Next"
|
||||
disabled={index === items.length - 1}
|
||||
onclick={() => step(1)}
|
||||
>
|
||||
<Icon name="next" size="1.75rem" />
|
||||
</button>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.media-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-media-viewer);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
:global(.media-content) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-media-viewer);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.media-title) {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.25rem;
|
||||
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-white);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.media-counter {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
:global(.media-close) {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 1rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.media-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.media-image,
|
||||
.media-video {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
.media-message {
|
||||
max-width: 22rem;
|
||||
color: var(--color-white);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-download {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
color: var(--color-white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.media-nav {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&.prev {
|
||||
left: 1.25rem;
|
||||
}
|
||||
|
||||
&.next {
|
||||
right: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script lang="ts">
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import EntityText from "$lib/components/EntityText.svelte";
|
||||
import ForwardHeader from "$lib/components/ForwardHeader.svelte";
|
||||
import MediaAlbum from "$lib/components/MediaAlbum.svelte";
|
||||
import MessageMedia from "$lib/components/MessageMedia.svelte";
|
||||
import MessageMeta from "$lib/components/MessageMeta.svelte";
|
||||
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
import { appear } from "$lib/transitions/appear";
|
||||
|
||||
interface Props {
|
||||
animate: boolean;
|
||||
firstInGroup: boolean;
|
||||
highlighted: boolean;
|
||||
isGroupChat: boolean;
|
||||
lastInGroup: boolean;
|
||||
message: MessageView;
|
||||
onjump: (messageId: number) => void;
|
||||
onmedia: (index: number) => void;
|
||||
onversions: () => void;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
message,
|
||||
own,
|
||||
isGroupChat,
|
||||
firstInGroup,
|
||||
lastInGroup,
|
||||
highlighted,
|
||||
animate,
|
||||
onjump,
|
||||
onmedia,
|
||||
onversions,
|
||||
}: Props = $props();
|
||||
|
||||
const deleted = $derived(message.deleted_at !== null);
|
||||
const hasText = $derived(Boolean(message.text));
|
||||
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const sender = $derived(
|
||||
message.sender_id === null ? undefined : peers.get(message.sender_id)
|
||||
);
|
||||
const senderName = $derived.by(() => {
|
||||
if (own) {
|
||||
return accounts.selected ? accountName(accounts.selected) : "You";
|
||||
}
|
||||
if (sender) {
|
||||
return peerName(sender);
|
||||
}
|
||||
return message.sender_id === null ? "" : String(message.sender_id);
|
||||
});
|
||||
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
|
||||
const showName = $derived(isGroupChat && !own && firstInGroup);
|
||||
|
||||
const avatarId = $derived(own ? ownId : message.sender_id);
|
||||
const avatarHas = $derived(
|
||||
own
|
||||
? ownId !== null && peers.get(ownId)?.has_avatar
|
||||
: (sender?.has_avatar ?? false)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="Message"
|
||||
class:own
|
||||
class:deleted
|
||||
class:highlighted
|
||||
class:first-in-group={firstInGroup}
|
||||
class:last-in-group={lastInGroup}
|
||||
class:with-avatar={isGroupChat}
|
||||
data-message-id={message.message_id}
|
||||
in:appear={{ disabled: !animate }}
|
||||
>
|
||||
{#if isGroupChat}
|
||||
<div class="avatar-slot">
|
||||
{#if lastInGroup && avatarId !== null}
|
||||
<Avatar
|
||||
name={senderName}
|
||||
colorKey={avatarId}
|
||||
size={2.125}
|
||||
avatar={{ kind: "peer", id: avatarId }}
|
||||
hasAvatar={avatarHas}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="message-content" class:has-appendix={lastInGroup}>
|
||||
{#if showName}
|
||||
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
|
||||
{/if}
|
||||
{#if message.forward}
|
||||
<ForwardHeader forward={message.forward} />
|
||||
{/if}
|
||||
{#if message.reply}
|
||||
<ReplyHeader reply={message.reply} {onjump} />
|
||||
{/if}
|
||||
{#if deleted}
|
||||
<span class="deleted-tag">
|
||||
<Icon name="delete" size="0.875rem" />
|
||||
deleted
|
||||
</span>
|
||||
{/if}
|
||||
{#if message.media.length > 1}
|
||||
<MediaAlbum
|
||||
media={message.media}
|
||||
chatId={message.chat_id}
|
||||
onopen={onmedia}
|
||||
/>
|
||||
{:else if message.has_media}
|
||||
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
|
||||
{/if}
|
||||
{#if hasText}
|
||||
<div class="text">
|
||||
<EntityText
|
||||
text={message.text ?? ""}
|
||||
entities={message.entities}
|
||||
{own}
|
||||
/>
|
||||
</div>
|
||||
{:else if !(message.has_media || deleted)}
|
||||
<div class="text empty">(no text)</div>
|
||||
{/if}
|
||||
<MessageMeta {message} {onversions} />
|
||||
{#if lastInGroup}
|
||||
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
|
||||
<path
|
||||
class="corner"
|
||||
d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.Message {
|
||||
--background-color: var(--color-background);
|
||||
--meta-color: var(--color-text-meta);
|
||||
|
||||
position: relative;
|
||||
transform-origin: bottom left;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.4375rem;
|
||||
margin-bottom: 0.125rem;
|
||||
|
||||
&.last-in-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&.own {
|
||||
--background-color: var(--color-background-own);
|
||||
--meta-color: var(--color-message-meta-own);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-slot {
|
||||
flex-shrink: 0;
|
||||
width: 2.125rem;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
position: relative;
|
||||
|
||||
max-width: min(30rem, 75%);
|
||||
min-width: 3.5rem;
|
||||
padding: 0.3125rem 0.5rem 0.375rem;
|
||||
border-radius: var(--border-radius-messages);
|
||||
|
||||
font-size: 1rem;
|
||||
line-height: 1.3125;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: var(--background-color);
|
||||
box-shadow: 0 1px 2px var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.Message.highlighted .message-content {
|
||||
animation: highlight-flash 1.6s ease;
|
||||
}
|
||||
|
||||
@keyframes highlight-flash {
|
||||
0%,
|
||||
60% {
|
||||
background-color: var(--color-primary-opacity);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
}
|
||||
|
||||
.Message:not(.first-in-group) .message-content {
|
||||
border-top-left-radius: var(--border-radius-messages-small);
|
||||
}
|
||||
|
||||
.Message:not(.last-in-group) .message-content {
|
||||
border-bottom-left-radius: var(--border-radius-messages-small);
|
||||
}
|
||||
|
||||
.Message.last-in-group .message-content.has-appendix {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.0625rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.empty {
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.deleted .text {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.deleted-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.svg-appendix {
|
||||
position: absolute;
|
||||
bottom: -0.0625rem;
|
||||
left: -0.5rem;
|
||||
|
||||
width: 9px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.svg-appendix .corner {
|
||||
fill: var(--background-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { listMessages } from "$lib/api/endpoints";
|
||||
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import MediaViewer from "$lib/components/MediaViewer.svelte";
|
||||
import MessageBubble from "$lib/components/MessageBubble.svelte";
|
||||
import MessageVersions from "$lib/components/MessageVersions.svelte";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatDay } from "$lib/format/datetime";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
let { chatId }: Props = $props();
|
||||
|
||||
const skeletonBubbles = [42, 66, 28, 54, 38, 72, 46];
|
||||
|
||||
const PAGE = 60;
|
||||
const SCROLL_THRESHOLD = 160;
|
||||
const STICK_OFFSET = 9;
|
||||
const IDLE_DELAY = 1500;
|
||||
|
||||
let messages = $state<MessageView[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadingOlder = $state(false);
|
||||
let hasMore = $state(true);
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let suppressAppear = $state(false);
|
||||
let scrolling = $state(false);
|
||||
let stuckDay = $state<string | null>(null);
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let mediaOpen = $state(false);
|
||||
let mediaItems = $state<ViewerItem[]>([]);
|
||||
let mediaIndex = $state(0);
|
||||
let versionsOpen = $state(false);
|
||||
let versionsMessageId = $state<number | null>(null);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const isGroupChat = $derived(chatId < 0);
|
||||
|
||||
interface Row {
|
||||
dayKey: string;
|
||||
daySeparator: string | null;
|
||||
firstInGroup: boolean;
|
||||
lastInGroup: boolean;
|
||||
message: MessageView;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
function dayKey(iso: string): string {
|
||||
return new Date(iso).toDateString();
|
||||
}
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const prev = messages[i - 1];
|
||||
const next = messages[i + 1];
|
||||
const day = dayKey(message.date);
|
||||
const samePrevDay = prev ? dayKey(prev.date) === day : false;
|
||||
const sameNextDay = next ? dayKey(next.date) === day : false;
|
||||
out.push({
|
||||
message,
|
||||
dayKey: day,
|
||||
own: ownId !== null && message.sender_id === ownId,
|
||||
firstInGroup:
|
||||
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
|
||||
lastInGroup:
|
||||
!next || next.sender_id !== message.sender_id || !sameNextDay,
|
||||
daySeparator: samePrevDay ? null : formatDay(message.date),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function ensurePeers(items: MessageView[]) {
|
||||
const ids = new Set<number>();
|
||||
for (const message of items) {
|
||||
if (message.sender_id !== null) {
|
||||
ids.add(message.sender_id);
|
||||
}
|
||||
if (message.reply?.sender_id != null) {
|
||||
ids.add(message.reply.sender_id);
|
||||
}
|
||||
if (message.forward?.from_id != null) {
|
||||
ids.add(message.forward.from_id);
|
||||
}
|
||||
}
|
||||
if (ownId !== null) {
|
||||
ids.add(ownId);
|
||||
}
|
||||
peers.ensure(ids);
|
||||
}
|
||||
|
||||
function jumpToMessage(messageId: number) {
|
||||
const target = container?.querySelector<HTMLElement>(
|
||||
`[data-message-id="${messageId}"]`
|
||||
);
|
||||
if (!target) {
|
||||
toasts.error("Message not loaded");
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
highlightId = messageId;
|
||||
if (highlightTimer) {
|
||||
clearTimeout(highlightTimer);
|
||||
}
|
||||
highlightTimer = setTimeout(() => {
|
||||
highlightId = null;
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStuck() {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
|
||||
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
|
||||
let day: string | null = null;
|
||||
for (const sep of seps) {
|
||||
if (sep.getBoundingClientRect().top <= limit) {
|
||||
day = sep.dataset.day ?? null;
|
||||
}
|
||||
}
|
||||
stuckDay = day;
|
||||
}
|
||||
|
||||
async function loadInitial() {
|
||||
loading = true;
|
||||
hasMore = true;
|
||||
try {
|
||||
const page = await listMessages(chatId, {
|
||||
limit: PAGE,
|
||||
offset: 0,
|
||||
include_deleted: true,
|
||||
});
|
||||
messages = [...page].reverse();
|
||||
hasMore = page.length === PAGE;
|
||||
ensurePeers(messages);
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
updateStuck();
|
||||
} catch {
|
||||
toasts.error("Failed to load messages");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (loadingOlder || !hasMore || container === null) {
|
||||
return;
|
||||
}
|
||||
loadingOlder = true;
|
||||
suppressAppear = true;
|
||||
const el = container;
|
||||
const prevHeight = el.scrollHeight;
|
||||
const prevTop = el.scrollTop;
|
||||
try {
|
||||
const page = await listMessages(chatId, {
|
||||
limit: PAGE,
|
||||
offset: messages.length,
|
||||
include_deleted: true,
|
||||
});
|
||||
if (page.length > 0) {
|
||||
messages = [...[...page].reverse(), ...messages];
|
||||
ensurePeers(page);
|
||||
}
|
||||
hasMore = page.length === PAGE;
|
||||
await tick();
|
||||
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
suppressAppear = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
scrolling = true;
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
idleTimer = setTimeout(() => {
|
||||
scrolling = false;
|
||||
}, IDLE_DELAY);
|
||||
updateStuck();
|
||||
if (container && container.scrollTop < SCROLL_THRESHOLD) {
|
||||
loadOlder();
|
||||
}
|
||||
}
|
||||
|
||||
function openMedia(message: MessageView, index: number) {
|
||||
mediaItems = viewerItemsFrom(message.message_id, message.media);
|
||||
mediaIndex = index;
|
||||
mediaOpen = true;
|
||||
}
|
||||
|
||||
function openVersions(messageId: number) {
|
||||
versionsMessageId = messageId;
|
||||
versionsOpen = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const deps = {
|
||||
account: accounts.selectedId,
|
||||
revision: chats.revision,
|
||||
};
|
||||
if (deps.account === null) {
|
||||
return;
|
||||
}
|
||||
loadInitial();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="message-list custom-scroll"
|
||||
onscroll={onScroll}
|
||||
>
|
||||
{#if loading && messages.length === 0}
|
||||
<div class="messages-container">
|
||||
{#each skeletonBubbles as width, index (index)}
|
||||
<div class="bubble-skeleton skeleton" style:width="{width}%"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState
|
||||
title="No messages"
|
||||
description="This chat has no archived messages"
|
||||
/>
|
||||
{:else}
|
||||
<div class="messages-container">
|
||||
{#if loadingOlder}
|
||||
<div class="loading-older"><Spinner /></div>
|
||||
{/if}
|
||||
{#each rows as row (row.message.message_id)}
|
||||
{#if row.daySeparator}
|
||||
<div
|
||||
class="day-separator"
|
||||
class:idle={!scrolling && row.dayKey === stuckDay}
|
||||
data-day={row.dayKey}
|
||||
>
|
||||
<span>{row.daySeparator}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<MessageBubble
|
||||
message={row.message}
|
||||
own={row.own}
|
||||
{isGroupChat}
|
||||
firstInGroup={row.firstInGroup}
|
||||
lastInGroup={row.lastInGroup}
|
||||
highlighted={highlightId === row.message.message_id}
|
||||
animate={!suppressAppear}
|
||||
onjump={jumpToMessage}
|
||||
onmedia={(index) => openMedia(row.message, index)}
|
||||
onversions={() => openVersions(row.message.message_id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<MediaViewer
|
||||
bind:open={mediaOpen}
|
||||
bind:index={mediaIndex}
|
||||
{chatId}
|
||||
items={mediaItems}
|
||||
/>
|
||||
<MessageVersions
|
||||
bind:open={versionsOpen}
|
||||
{chatId}
|
||||
messageId={versionsMessageId}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
min-height: 100%;
|
||||
padding: 1rem max(1rem, calc((100% - var(--messages-container-width)) / 2));
|
||||
}
|
||||
|
||||
.loading-older {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.bubble-skeleton {
|
||||
align-self: flex-start;
|
||||
|
||||
height: 2.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--border-radius-messages);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.day-separator {
|
||||
pointer-events: none;
|
||||
|
||||
position: sticky;
|
||||
top: 0.5625rem;
|
||||
z-index: var(--z-sticky-date);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.75rem 0;
|
||||
|
||||
span {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&.idle span {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script lang="ts">
|
||||
import { visible } from "$lib/actions/visible";
|
||||
import { fetchMedia } from "$lib/api/endpoints";
|
||||
import {
|
||||
type InlineMedia,
|
||||
loadInlineMedia,
|
||||
visualKind,
|
||||
} from "$lib/api/media";
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import AudioFile from "$lib/components/media/AudioFile.svelte";
|
||||
import VideoNote from "$lib/components/media/VideoNote.svelte";
|
||||
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
onopen: () => void;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
let { message, onopen, own }: Props = $props();
|
||||
|
||||
const POLL_TRIES = 5;
|
||||
const POLL_DELAY = 3000;
|
||||
|
||||
let loaded = $state(false);
|
||||
let media = $state<InlineMedia | null>(null);
|
||||
let queuing = $state(false);
|
||||
|
||||
const ready = $derived(media?.state === "ready" ? media : null);
|
||||
const kind = $derived(ready?.kind ?? "");
|
||||
const mime = $derived(ready?.mime ?? "");
|
||||
const isImage = $derived(kind === "photo");
|
||||
const isStaticSticker = $derived(
|
||||
kind === "sticker" && mime.startsWith("image/")
|
||||
);
|
||||
const isVideoSticker = $derived(
|
||||
kind === "sticker" && mime.startsWith("video/")
|
||||
);
|
||||
const isTgsSticker = $derived(
|
||||
kind === "sticker" && mime === "application/x-tgsticker"
|
||||
);
|
||||
const isAnimation = $derived(kind === "animation" || kind === "gif");
|
||||
const isThumbVideo = $derived(kind === "video");
|
||||
|
||||
const vk = $derived(
|
||||
media && media.state !== "missing" ? visualKind(media.kind) : "other"
|
||||
);
|
||||
const label = $derived(
|
||||
media && media.state !== "missing" ? media.kind : "media"
|
||||
);
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
media = await loadInlineMedia(message.chat_id, message.message_id);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
for (let i = 0; i < POLL_TRIES; i++) {
|
||||
await delay(POLL_DELAY);
|
||||
const next = await loadInlineMedia(message.chat_id, message.message_id);
|
||||
if (next.state === "ready") {
|
||||
media = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queue() {
|
||||
if (queuing) {
|
||||
return;
|
||||
}
|
||||
queuing = true;
|
||||
try {
|
||||
await fetchMedia(message.chat_id, message.message_id);
|
||||
toasts.success("Download queued");
|
||||
poll();
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
} finally {
|
||||
queuing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="message-media" use:visible={start}>
|
||||
{#if message.is_self_destruct}
|
||||
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
||||
<Icon name="timer" size="1.25rem" />
|
||||
<span>Self-destruct media</span>
|
||||
</button>
|
||||
{:else if !loaded}
|
||||
<div class="media-skeleton"><Spinner /></div>
|
||||
{:else if ready && kind === "voice"}
|
||||
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
|
||||
{:else if ready && kind === "video_note"}
|
||||
<VideoNote url={ready.url} transcript={ready.transcript} />
|
||||
{:else if ready && kind === "audio"}
|
||||
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
|
||||
{:else if ready && isImage}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="attachment">
|
||||
</button>
|
||||
{:else if ready && isStaticSticker}
|
||||
<button class="media-sticker" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="sticker">
|
||||
</button>
|
||||
{:else if ready && isVideoSticker}
|
||||
<video
|
||||
class="media-sticker-video"
|
||||
src={ready.url}
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
{:else if ready && isTgsSticker}
|
||||
<div class="media-sticker tgs">
|
||||
<span class="tgs-emoji">{message.sticker?.emoji ?? "🎞"}</span>
|
||||
</div>
|
||||
{:else if ready && isAnimation}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<video src={ready.url} autoplay loop muted playsinline></video>
|
||||
<span class="gif-badge">GIF</span>
|
||||
</button>
|
||||
{:else if ready && isThumbVideo}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<video src={ready.url} muted preload="metadata"></video>
|
||||
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
|
||||
</button>
|
||||
{:else if ready}
|
||||
<button class="media-chip" onclick={onopen} type="button">
|
||||
<Icon name="document" size="1.25rem" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
||||
<button class="media-placeholder" onclick={queue} type="button">
|
||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
||||
<span>{vk === "video" ? "Video" : "Photo"}</span>
|
||||
<small>{queuing ? "Queued" : "Tap to download"}</small>
|
||||
</button>
|
||||
{:else if media?.state === "not-downloaded"}
|
||||
<button class="media-chip" onclick={queue} type="button">
|
||||
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
|
||||
<span>{queuing ? "Queued" : `Download ${label}`}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="media-chip" onclick={onopen} type="button">
|
||||
<Icon name="photo" size="1.25rem" />
|
||||
<span>Media</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.message-media {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
|
||||
img,
|
||||
video {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
max-height: 20rem;
|
||||
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.media-sticker {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
background: transparent;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.media-sticker-video {
|
||||
display: block;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tgs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.tgs-emoji {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gif-badge {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
left: 0.375rem;
|
||||
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
display: flex;
|
||||
color: var(--color-white);
|
||||
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.media-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 12rem;
|
||||
height: 9rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
width: 12rem;
|
||||
height: 9rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
color: var(--color-primary);
|
||||
text-align: center;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.media-chip {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
text-align: start;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
&.self-destruct {
|
||||
color: var(--color-orange);
|
||||
background-color: var(--color-light-coral);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import { formatTime } from "$lib/format/datetime";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
onversions?: () => void;
|
||||
}
|
||||
|
||||
let { message, onversions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="MessageMeta">
|
||||
{#if message.edited_at}
|
||||
<button
|
||||
type="button"
|
||||
class="edited"
|
||||
title="View edit history"
|
||||
onclick={onversions}
|
||||
>
|
||||
edited
|
||||
</button>
|
||||
{/if}
|
||||
<span class="time">{formatTime(message.date)}</span>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.MessageMeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
float: right;
|
||||
margin-top: 0.125rem;
|
||||
margin-inline-start: 0.5rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
color: var(--meta-color, var(--color-text-meta));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edited {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import { getMediaVersions, listMessageVersions } from "$lib/api/endpoints";
|
||||
import type { MediaVersion, MessageVersion } from "$lib/api/types";
|
||||
import MediaVersionThumb from "$lib/components/MediaVersionThumb.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatFull } from "$lib/format/datetime";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
messageId: number | null;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(), chatId, messageId }: Props = $props();
|
||||
|
||||
let versions = $state<MessageVersion[]>([]);
|
||||
let mediaVersions = $state<MediaVersion[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && messageId !== null) {
|
||||
loading = true;
|
||||
mediaVersions = [];
|
||||
const id = messageId;
|
||||
Promise.all([
|
||||
listMessageVersions(chatId, id),
|
||||
getMediaVersions(chatId, id),
|
||||
])
|
||||
.then(([text, media]) => {
|
||||
versions = text;
|
||||
mediaVersions = media;
|
||||
})
|
||||
.catch(() => toasts.error("Failed to load edit history"))
|
||||
.finally(() => {
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<Dialog.Title class="dialog-title">Edit history</Dialog.Title>
|
||||
<Dialog.Close class="dialog-close" aria-label="Close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div class="versions">
|
||||
{#if loading}
|
||||
<div class="centered"><Spinner /></div>
|
||||
{:else if versions.length === 0 && mediaVersions.length === 0}
|
||||
<p class="empty">No versions recorded.</p>
|
||||
{:else}
|
||||
{#if mediaVersions.length > 0}
|
||||
<div class="media-versions">
|
||||
<span class="media-label">Media versions</span>
|
||||
<div class="media-strip">
|
||||
{#each mediaVersions as media (media.id)}
|
||||
<MediaVersionThumb version={media} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each versions as version, index (version.observed_at)}
|
||||
<div class="version">
|
||||
<div class="version-meta">
|
||||
<span class="version-label">Version {index + 1}</span>
|
||||
<span class="version-date"
|
||||
>{formatFull(version.observed_at)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="version-text">{version.text ?? "(no text)"}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.dialog-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
:global(.dialog-content) {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: min(32rem, 92vw);
|
||||
max-height: 80vh;
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
:global(.dialog-title) {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
:global(.dialog-close) {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: 0.375rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.versions {
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.media-versions {
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.media-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.media-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.version {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.version-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.version-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1rem 0;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import type { ReplyView } from "$lib/api/types";
|
||||
import { mediaKindLabel } from "$lib/format/media";
|
||||
import { peerColorIndex, peerName } from "$lib/format/peer";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
interface Props {
|
||||
onjump?: (messageId: number) => void;
|
||||
reply: ReplyView;
|
||||
}
|
||||
|
||||
let { reply, onjump }: Props = $props();
|
||||
|
||||
const sender = $derived(
|
||||
reply.sender_id === null ? undefined : peers.get(reply.sender_id)
|
||||
);
|
||||
const name = $derived(
|
||||
sender
|
||||
? peerName(sender)
|
||||
: (reply.sender_name ?? (reply.sender_id ? String(reply.sender_id) : ""))
|
||||
);
|
||||
const colorIndex = $derived(peerColorIndex(reply.sender_id ?? 0));
|
||||
const preview = $derived(
|
||||
reply.text || mediaKindLabel(reply.media_kind) || ""
|
||||
);
|
||||
const mediaOnly = $derived(!reply.text && reply.media_kind !== null);
|
||||
const canJump = $derived(reply.message_id !== null && onjump !== undefined);
|
||||
|
||||
function jump() {
|
||||
if (reply.message_id !== null) {
|
||||
onjump?.(reply.message_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ReplyHeader peer-color-{colorIndex}"
|
||||
class:clickable={canJump}
|
||||
disabled={!canJump}
|
||||
onclick={jump}
|
||||
>
|
||||
<span class="bar"></span>
|
||||
<span class="body">
|
||||
<span class="title">{name || "Reply"}</span>
|
||||
<span class="preview" class:media={mediaOnly}>{preview}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.ReplyHeader {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 0.1875rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
text-align: left;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
background-color: var(--color-primary-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
width: 0.1875rem;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: currentColor;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.media {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { visible } from "$lib/actions/visible";
|
||||
import { fetchMedia } from "$lib/api/endpoints";
|
||||
import { type InlineMedia, loadMediaItem, visualKind } from "$lib/api/media";
|
||||
import type { MediaRef } from "$lib/api/types";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
media: MediaRef;
|
||||
onopen: () => void;
|
||||
}
|
||||
|
||||
let { media, chatId, onopen }: Props = $props();
|
||||
|
||||
let loaded = $state(false);
|
||||
let inline = $state<InlineMedia | null>(null);
|
||||
let queuing = $state(false);
|
||||
|
||||
const ready = $derived(inline?.state === "ready" ? inline : null);
|
||||
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
|
||||
|
||||
async function start() {
|
||||
inline = await loadMediaItem(media);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function queue() {
|
||||
if (queuing) {
|
||||
return;
|
||||
}
|
||||
queuing = true;
|
||||
try {
|
||||
await fetchMedia(chatId, media.message_id);
|
||||
toasts.success("Download queued");
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
} finally {
|
||||
queuing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="AlbumTile" use:visible={start}>
|
||||
{#if ready && isVideo}
|
||||
<button class="tile" onclick={onopen} type="button">
|
||||
<video src={ready.url} muted preload="metadata"></video>
|
||||
<span class="play"><Icon name="large-play" size="2rem" /></span>
|
||||
</button>
|
||||
{:else if ready}
|
||||
<button class="tile" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="attachment">
|
||||
</button>
|
||||
{:else if inline?.state === "not-downloaded"}
|
||||
<button class="tile placeholder" onclick={queue} type="button">
|
||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
||||
</button>
|
||||
{:else if loaded}
|
||||
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
|
||||
{:else}
|
||||
<div class="tile placeholder"><Spinner /></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.AlbumTile {
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tile {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
|
||||
img,
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
color: var(--color-white);
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { formatDuration } from "$lib/format/duration";
|
||||
import { claimPlayback, releasePlayback } from "$lib/media/playback";
|
||||
|
||||
interface Props {
|
||||
own: boolean;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let { url, title, own }: Props = $props();
|
||||
|
||||
let element = $state<HTMLAudioElement>();
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let paused = $state(true);
|
||||
|
||||
const progress = $derived(duration > 0 ? currentTime / duration : 0);
|
||||
|
||||
function toggle() {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
element.play().catch(() => undefined);
|
||||
} else {
|
||||
element.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function seek(event: PointerEvent) {
|
||||
if (!(element && duration > 0)) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget as HTMLElement;
|
||||
const ratio =
|
||||
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
|
||||
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="Audio" class:own>
|
||||
<button class="toggle" onclick={toggle} type="button" aria-label="Play audio">
|
||||
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
|
||||
</button>
|
||||
<div class="content">
|
||||
<div class="title">{title}</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="seekline" onpointerdown={seek}>
|
||||
<div class="track"></div>
|
||||
<div class="fill" style:width={`${Math.round(progress * 100)}%`}></div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{formatDuration(currentTime)}
|
||||
/ {formatDuration(duration)}
|
||||
</div>
|
||||
</div>
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived audio track has no captions -->
|
||||
<audio
|
||||
bind:this={element}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
onended={() => element && releasePlayback(element)}
|
||||
onplay={() => element && claimPlayback(element)}
|
||||
preload="metadata"
|
||||
src={url}
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.Audio {
|
||||
--active: var(--color-primary);
|
||||
--toggle-fg: var(--color-white);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
min-width: 15rem;
|
||||
padding: 0.1875rem 0;
|
||||
|
||||
&.own {
|
||||
--active: var(--color-white);
|
||||
--toggle-fg: var(--color-background-own);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--toggle-fg);
|
||||
|
||||
background-color: var(--active);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seekline {
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.track,
|
||||
.fill {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.track {
|
||||
width: 100%;
|
||||
background-color: var(--color-interactive-inactive);
|
||||
}
|
||||
|
||||
.fill {
|
||||
background-color: var(--active);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { formatDuration } from "$lib/format/duration";
|
||||
import { claimPlayback, releasePlayback } from "$lib/media/playback";
|
||||
|
||||
interface Props {
|
||||
transcript?: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let { url, transcript = null }: Props = $props();
|
||||
|
||||
let showTranscript = $state(false);
|
||||
|
||||
const RADIUS = 94;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
let element = $state<HTMLVideoElement>();
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let paused = $state(true);
|
||||
|
||||
const progress = $derived(duration > 0 ? currentTime / duration : 0);
|
||||
const remaining = $derived(Math.max(0, duration - currentTime));
|
||||
|
||||
function toggle() {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
element.play().catch(() => undefined);
|
||||
} else {
|
||||
element.pause();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="RoundVideoWrap">
|
||||
<button
|
||||
class="RoundVideo"
|
||||
onclick={toggle}
|
||||
type="button"
|
||||
aria-label="Play video message"
|
||||
>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
|
||||
<video
|
||||
bind:this={element}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
onended={() => element && releasePlayback(element)}
|
||||
onplay={() => element && claimPlayback(element)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
src={url}
|
||||
></video>
|
||||
<svg class="ring" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle
|
||||
class="ring-progress"
|
||||
cx="100"
|
||||
cy="100"
|
||||
r={RADIUS}
|
||||
stroke-dasharray={CIRCUMFERENCE}
|
||||
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
|
||||
/>
|
||||
</svg>
|
||||
{#if paused}
|
||||
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
|
||||
{/if}
|
||||
<span class="badge">
|
||||
<Icon name="microphone" size="0.875rem" />
|
||||
{formatDuration(paused && currentTime === 0 ? duration : remaining)}
|
||||
</span>
|
||||
</button>
|
||||
{#if transcript}
|
||||
<button
|
||||
class="transcribe"
|
||||
class:active={showTranscript}
|
||||
onclick={() => (showTranscript = !showTranscript)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="transcribe" size="1rem" />
|
||||
Transcription
|
||||
</button>
|
||||
{#if showTranscript}
|
||||
<div class="transcript">{transcript}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.RoundVideoWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.transcribe {
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.transcript {
|
||||
max-width: 16rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.RoundVideo {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
display: block;
|
||||
|
||||
width: 13rem;
|
||||
height: 13rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
|
||||
object-fit: cover;
|
||||
background-color: var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.ring {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotate(-90deg);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ring-progress {
|
||||
fill: transparent;
|
||||
stroke: var(--color-white);
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
stroke-opacity: 0.9;
|
||||
transition: stroke-dashoffset 0.2s linear;
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
display: flex;
|
||||
color: var(--color-white);
|
||||
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { formatDuration } from "$lib/format/duration";
|
||||
import { claimPlayback, releasePlayback } from "$lib/media/playback";
|
||||
import { computeWaveform, flatWaveform } from "$lib/media/waveform";
|
||||
|
||||
interface Props {
|
||||
own: boolean;
|
||||
transcript?: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let { url, own, transcript = null }: Props = $props();
|
||||
|
||||
let showTranscript = $state(false);
|
||||
let element = $state<HTMLAudioElement>();
|
||||
let peaks = $state<number[]>(flatWaveform());
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let paused = $state(true);
|
||||
|
||||
const progress = $derived(duration > 0 ? currentTime / duration : 0);
|
||||
const elapsed = $derived(
|
||||
paused && currentTime === 0 ? duration : currentTime
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
let active = true;
|
||||
computeWaveform(url)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
peaks = result;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
element.play().catch(() => undefined);
|
||||
} else {
|
||||
element.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function seek(event: PointerEvent) {
|
||||
if (!(element && duration > 0)) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget as HTMLElement;
|
||||
const ratio =
|
||||
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
|
||||
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="VoiceWrap" class:own>
|
||||
<div class="Voice">
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={toggle}
|
||||
type="button"
|
||||
aria-label="Play voice"
|
||||
>
|
||||
<Icon name={paused ? "play" : "pause"} size="1.5rem" />
|
||||
</button>
|
||||
<div class="body">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="waveform" onpointerdown={seek}>
|
||||
{#each peaks as peak, i (i)}
|
||||
<span
|
||||
class="bar"
|
||||
class:filled={i / peaks.length < progress}
|
||||
style:height={`${Math.round(peak * 100)}%`}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="time">{formatDuration(elapsed)}</div>
|
||||
</div>
|
||||
{#if transcript}
|
||||
<button
|
||||
class="transcribe"
|
||||
class:active={showTranscript}
|
||||
onclick={() => (showTranscript = !showTranscript)}
|
||||
type="button"
|
||||
aria-label="Show transcription"
|
||||
>
|
||||
<Icon name="transcribe" size="1.125rem" />
|
||||
</button>
|
||||
{/if}
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
|
||||
<audio
|
||||
bind:this={element}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
onplay={() => element && claimPlayback(element)}
|
||||
onended={() => element && releasePlayback(element)}
|
||||
preload="metadata"
|
||||
src={url}
|
||||
></audio>
|
||||
</div>
|
||||
{#if transcript && showTranscript}
|
||||
<div class="transcript">{transcript}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.VoiceWrap {
|
||||
--active: var(--color-primary);
|
||||
--inactive: var(--color-interactive-inactive);
|
||||
--toggle-fg: var(--color-white);
|
||||
--voice-meta: var(--color-text-secondary);
|
||||
--transcribe-bg: var(--color-primary-tint);
|
||||
|
||||
&.own {
|
||||
--active: var(--color-white);
|
||||
--inactive: rgba(255, 255, 255, 0.45);
|
||||
--toggle-fg: var(--color-background-own);
|
||||
--voice-meta: rgba(255, 255, 255, 0.75);
|
||||
--transcribe-bg: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.Voice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
min-width: 13rem;
|
||||
padding: 0.1875rem 0;
|
||||
}
|
||||
|
||||
.transcribe {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--voice-meta);
|
||||
|
||||
background-color: var(--transcribe-bg);
|
||||
|
||||
&.active {
|
||||
color: var(--toggle-fg);
|
||||
background-color: var(--active);
|
||||
}
|
||||
}
|
||||
|
||||
.transcript {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--color-borders);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--toggle-fg);
|
||||
|
||||
background-color: var(--active);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
min-height: 2px;
|
||||
border-radius: 1px;
|
||||
|
||||
background-color: var(--inactive);
|
||||
|
||||
&.filled {
|
||||
background-color: var(--active);
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-top: 0.25rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--voice-meta);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { type AvatarKind, loadAvatar } from "$lib/api/avatars";
|
||||
import { initials as toInitials } from "$lib/format/peer";
|
||||
|
||||
interface Props {
|
||||
avatar?: { kind: AvatarKind; id: number } | null;
|
||||
colorKey?: number;
|
||||
deleted?: boolean;
|
||||
hasAvatar?: boolean;
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
colorKey = 0,
|
||||
size = 3.375,
|
||||
deleted = false,
|
||||
avatar = null,
|
||||
hasAvatar = false,
|
||||
}: Props = $props();
|
||||
|
||||
const gradients = [
|
||||
["#ff885e", "#ff516a"],
|
||||
["#ffcd6a", "#ffa85c"],
|
||||
["#82b1ff", "#665fff"],
|
||||
["#a0de7e", "#54cb68"],
|
||||
["#53edd6", "#28c9b7"],
|
||||
["#72d5fd", "#2a9ef1"],
|
||||
["#e0a2f3", "#d669ed"],
|
||||
];
|
||||
|
||||
const pair = $derived(gradients[Math.abs(colorKey) % gradients.length]);
|
||||
const initials = $derived(toInitials(name));
|
||||
|
||||
let url = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
url = null;
|
||||
if (deleted || !(hasAvatar && avatar)) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
|
||||
if (active) {
|
||||
url = resolved;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="Avatar"
|
||||
class:deleted
|
||||
style="
|
||||
--avatar-size: {size}rem;
|
||||
--avatar-from: {pair[0]};
|
||||
--avatar-to: {pair[1]};
|
||||
"
|
||||
>
|
||||
{#if url}
|
||||
<img class="photo" src={url} alt={name}>
|
||||
{:else}
|
||||
<span class="initials">{initials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.Avatar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
overflow: hidden;
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
border-radius: 50%;
|
||||
|
||||
font-size: calc(var(--avatar-size) * 0.4);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
color: var(--color-white);
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
|
||||
background-image: linear-gradient(var(--avatar-from), var(--avatar-to));
|
||||
|
||||
&.deleted {
|
||||
background-image: none;
|
||||
background-color: var(--color-deleted-account);
|
||||
}
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
|
||||
type Variant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "gray"
|
||||
| "danger"
|
||||
| "green"
|
||||
| "translucent"
|
||||
| "text";
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
children: Snippet;
|
||||
fluid?: boolean;
|
||||
loading?: boolean;
|
||||
pill?: boolean;
|
||||
round?: boolean;
|
||||
smaller?: boolean;
|
||||
tiny?: boolean;
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = "primary",
|
||||
round = false,
|
||||
tiny = false,
|
||||
smaller = false,
|
||||
pill = false,
|
||||
fluid = false,
|
||||
loading = false,
|
||||
type = "button",
|
||||
disabled = false,
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="Button {variant}"
|
||||
class:round
|
||||
class:tiny
|
||||
class:smaller
|
||||
class:pill
|
||||
class:fluid
|
||||
class:loading
|
||||
class:disabled
|
||||
{type}
|
||||
{disabled}
|
||||
use:ripple
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.Button {
|
||||
--button-text-color: white;
|
||||
--button-background-color: transparent;
|
||||
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 3rem;
|
||||
padding: 0.625rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-button);
|
||||
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
color: var(--button-text-color);
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
|
||||
background-color: var(--button-background-color);
|
||||
outline: none;
|
||||
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s,
|
||||
opacity 0.2s;
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:not(.disabled):active,
|
||||
&:not(.disabled):focus {
|
||||
color: var(--button-active-text-color, var(--button-text-color));
|
||||
background-color: var(
|
||||
--button-active-background-color,
|
||||
var(--button-background-color)
|
||||
);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:not(.disabled):hover {
|
||||
color: var(--button-active-text-color, var(--button-text-color));
|
||||
background-color: var(
|
||||
--button-active-background-color,
|
||||
var(--button-background-color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-white);
|
||||
--button-background-color: var(--color-primary);
|
||||
--button-active-background-color: var(--color-primary-shade);
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-text-secondary);
|
||||
--button-background-color: var(--color-background);
|
||||
--button-active-text-color: white;
|
||||
--button-active-background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-text-secondary);
|
||||
--button-background-color: var(--color-background);
|
||||
--button-active-text-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
--ripple-color: rgba(var(--color-error-rgb), 0.16);
|
||||
--button-text-color: var(--color-error);
|
||||
--button-background-color: var(--color-background);
|
||||
--button-active-text-color: var(--color-white);
|
||||
--button-active-background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&.green {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-white);
|
||||
--button-background-color: var(--color-green);
|
||||
--button-active-background-color: var(--color-green-darker);
|
||||
}
|
||||
|
||||
&.translucent {
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
--button-text-color: var(--color-text-secondary);
|
||||
--button-background-color: transparent;
|
||||
--button-active-background-color: var(--color-interactive-element-hover);
|
||||
}
|
||||
|
||||
&.text {
|
||||
--button-background-color: transparent;
|
||||
--button-text-color: var(--color-primary);
|
||||
--button-active-background-color: rgba(var(--color-primary-shade-rgb), 0.08);
|
||||
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
&.fluid {
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
&.pill {
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
border-radius: 1.75rem;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
&.smaller {
|
||||
height: 2.5rem;
|
||||
padding: 0.3125rem;
|
||||
|
||||
&.round {
|
||||
width: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.tiny {
|
||||
height: 2.25rem;
|
||||
padding: 0.4375rem;
|
||||
border-radius: var(--border-radius-button-tiny);
|
||||
font-size: 0.875rem;
|
||||
|
||||
&.round {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&.round {
|
||||
width: 3rem;
|
||||
border-radius: 50%;
|
||||
|
||||
:global(.icon) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
description?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
let { title, description }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="title">{title}</div>
|
||||
{#if description}
|
||||
<div class="description">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 100%;
|
||||
margin-bottom: 0.125rem;
|
||||
|
||||
font-size: 1.25rem;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
name: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
let { name, size, class: extra = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<i
|
||||
class="icon icon-{name} {extra}"
|
||||
style={size ? `font-size: ${size}` : undefined}
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
onclick?: () => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
let { selected = false, onclick, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ListItem-button"
|
||||
class:selected
|
||||
use:ripple
|
||||
{onclick}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.ListItem-button {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
font-size: 1rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: var(--color-white);
|
||||
background-color: var(--color-chat-active);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
circle?: boolean;
|
||||
height?: string;
|
||||
radius?: string;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
width = "100%",
|
||||
height = "1rem",
|
||||
radius = "0.375rem",
|
||||
circle = false,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton"
|
||||
style:width
|
||||
style:height
|
||||
style:border-radius={circle ? "50%" : radius}
|
||||
></div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
color?: "blue" | "white" | "gray";
|
||||
size?: string;
|
||||
}
|
||||
|
||||
let { size = "2rem", color = "blue" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="Spinner {color}" style="--spinner-size: {size}"></div>
|
||||
|
||||
<style lang="scss">
|
||||
.Spinner {
|
||||
width: var(--spinner-size);
|
||||
height: var(--spinner-size);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
animation: spinner-rotate 800ms linear infinite;
|
||||
|
||||
&.blue {
|
||||
background-image: var(--spinner-blue-data);
|
||||
}
|
||||
|
||||
&.white {
|
||||
background-image: var(--spinner-white-data);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
background-image: var(--spinner-gray-data);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
</script>
|
||||
|
||||
<div class="Notification-container">
|
||||
{#each toasts.items as toast (toast.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="Notification {toast.type}"
|
||||
onclick={() => toasts.dismiss(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.Notification-container {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
z-index: var(--z-notification);
|
||||
right: 0;
|
||||
bottom: 1.25rem;
|
||||
left: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.Notification {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
|
||||
max-width: 22rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-toast);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: var(--color-toast-background);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
|
||||
|
||||
&.error {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: var(--color-green);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const MEDIA_KIND_LABELS: Record<string, string> = {
|
||||
photo: "Photo",
|
||||
video: "Video",
|
||||
animation: "GIF",
|
||||
gif: "GIF",
|
||||
voice: "Voice message",
|
||||
audio: "Audio",
|
||||
video_note: "Video message",
|
||||
sticker: "Sticker",
|
||||
document: "File",
|
||||
contact: "Contact",
|
||||
location: "Location",
|
||||
venue: "Location",
|
||||
poll: "Poll",
|
||||
dice: "Dice",
|
||||
game: "Game",
|
||||
story: "Story",
|
||||
};
|
||||
|
||||
export function mediaKindLabel(kind: string | null): string | null {
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
return MEDIA_KIND_LABELS[kind] ?? "Media";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const BARS = 48;
|
||||
const MIN_BAR = 0.08;
|
||||
|
||||
export async function computeWaveform(
|
||||
url: string,
|
||||
bars = BARS
|
||||
): Promise<number[]> {
|
||||
const response = await fetch(url);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const ctx = new AudioContext();
|
||||
try {
|
||||
const audio = await ctx.decodeAudioData(buffer);
|
||||
const data = audio.getChannelData(0);
|
||||
const block = Math.max(1, Math.floor(data.length / bars));
|
||||
const peaks: number[] = [];
|
||||
for (let i = 0; i < bars; i++) {
|
||||
const start = i * block;
|
||||
let max = 0;
|
||||
for (let j = 0; j < block; j++) {
|
||||
const value = Math.abs(data[start + j] ?? 0);
|
||||
if (value > max) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
peaks.push(max);
|
||||
}
|
||||
const norm = Math.max(...peaks, 0.0001);
|
||||
return peaks.map((peak) => Math.max(MIN_BAR, peak / norm));
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function flatWaveform(bars = BARS): number[] {
|
||||
return Array.from({ length: bars }, () => 0.3);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { listAccounts } from "$lib/api/endpoints";
|
||||
import type { Account } from "$lib/api/types";
|
||||
|
||||
const STORAGE_KEY = "bg.account";
|
||||
|
||||
function readSelected(): number | null {
|
||||
if (!browser) {
|
||||
return null;
|
||||
}
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === null ? null : Number(stored);
|
||||
}
|
||||
|
||||
function createAccounts() {
|
||||
let list = $state<Account[]>([]);
|
||||
let selectedId = $state<number | null>(readSelected());
|
||||
let loaded = $state(false);
|
||||
|
||||
const selected = $derived(
|
||||
list.find((account) => account.account_id === selectedId) ?? null
|
||||
);
|
||||
|
||||
function persist(id: number | null) {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
if (id === null) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(STORAGE_KEY, String(id));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get list() {
|
||||
return list;
|
||||
},
|
||||
get selectedId() {
|
||||
return selectedId;
|
||||
},
|
||||
get selected() {
|
||||
return selected;
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
},
|
||||
async load() {
|
||||
list = await listAccounts();
|
||||
loaded = true;
|
||||
const exists = list.some((account) => account.account_id === selectedId);
|
||||
if (!exists) {
|
||||
const fallback =
|
||||
list.find((account) => account.is_active) ?? list.at(0) ?? null;
|
||||
selectedId = fallback ? fallback.account_id : null;
|
||||
persist(selectedId);
|
||||
}
|
||||
},
|
||||
select(id: number) {
|
||||
selectedId = id;
|
||||
persist(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const accounts = createAccounts();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const STORAGE_KEY = "bg.token";
|
||||
|
||||
function readToken(): string | null {
|
||||
if (!browser) {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function createAuth() {
|
||||
let token = $state<string | null>(readToken());
|
||||
|
||||
return {
|
||||
get token() {
|
||||
return token;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return token !== null && token.length > 0;
|
||||
},
|
||||
login(value: string) {
|
||||
token = value;
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
token = null;
|
||||
if (browser) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuth();
|
||||
@@ -0,0 +1,93 @@
|
||||
import { enrichChat, getJob, listChats } from "$lib/api/endpoints";
|
||||
import type { Chat } from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
const POLL_INTERVAL = 1500;
|
||||
const POLL_MAX = 12;
|
||||
|
||||
function createChats() {
|
||||
let list = $state<Chat[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loading = $state(false);
|
||||
let revision = $state(0);
|
||||
let account: number | null = null;
|
||||
const enriched = new Set<number>();
|
||||
|
||||
function syncAccount() {
|
||||
if (accounts.selectedId !== account) {
|
||||
account = accounts.selectedId;
|
||||
list = [];
|
||||
loaded = false;
|
||||
enriched.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async function load(force: boolean) {
|
||||
syncAccount();
|
||||
if (account === null || (loaded && !force)) {
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
list = await listChats({ limit: 200 });
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: number) {
|
||||
for (let attempt = 0; attempt < POLL_MAX; attempt++) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, POLL_INTERVAL);
|
||||
});
|
||||
const job = await getJob(jobId);
|
||||
if (job.status === "done" || job.status === "failed") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get list(): Chat[] {
|
||||
return list;
|
||||
},
|
||||
get loaded(): boolean {
|
||||
return loaded;
|
||||
},
|
||||
get loading(): boolean {
|
||||
return loading;
|
||||
},
|
||||
get revision(): number {
|
||||
return revision;
|
||||
},
|
||||
byId(id: number): Chat | undefined {
|
||||
return list.find((chat) => chat.chat_id === id);
|
||||
},
|
||||
load() {
|
||||
return load(false);
|
||||
},
|
||||
refresh() {
|
||||
return load(true);
|
||||
},
|
||||
async enrich(chatId: number) {
|
||||
syncAccount();
|
||||
if (account === null || enriched.has(chatId)) {
|
||||
return;
|
||||
}
|
||||
enriched.add(chatId);
|
||||
try {
|
||||
const { job_id } = await enrichChat(chatId);
|
||||
await waitForJob(job_id);
|
||||
peers.reset();
|
||||
await load(true);
|
||||
revision++;
|
||||
} catch {
|
||||
enriched.delete(chatId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const chats = createChats();
|
||||
@@ -0,0 +1,74 @@
|
||||
import { listFolders } from "$lib/api/endpoints";
|
||||
import type { Folder } from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
function createFolders() {
|
||||
let list = $state<Folder[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loading = $state(false);
|
||||
let selectedId = $state<number | null>(null);
|
||||
let direction = $state(1);
|
||||
let account: number | null = null;
|
||||
|
||||
function syncAccount() {
|
||||
if (accounts.selectedId !== account) {
|
||||
account = accounts.selectedId;
|
||||
list = [];
|
||||
loaded = false;
|
||||
selectedId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function indexOf(id: number | null): number {
|
||||
if (id === null) {
|
||||
return 0;
|
||||
}
|
||||
const index = list.findIndex((folder) => folder.folder_id === id);
|
||||
return index === -1 ? 0 : index + 1;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
syncAccount();
|
||||
if (account === null || loaded) {
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const folders = await listFolders();
|
||||
list = folders.sort((a, b) => a.order_index - b.order_index);
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get list(): Folder[] {
|
||||
return list;
|
||||
},
|
||||
get loading(): boolean {
|
||||
return loading;
|
||||
},
|
||||
get selectedId(): number | null {
|
||||
return selectedId;
|
||||
},
|
||||
get selected(): Folder | null {
|
||||
return list.find((folder) => folder.folder_id === selectedId) ?? null;
|
||||
},
|
||||
get activeIndex(): number {
|
||||
return indexOf(selectedId);
|
||||
},
|
||||
get direction(): number {
|
||||
return direction;
|
||||
},
|
||||
load() {
|
||||
return load();
|
||||
},
|
||||
select(id: number | null) {
|
||||
direction = indexOf(id) >= indexOf(selectedId) ? 1 : -1;
|
||||
selectedId = id;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const folders = createFolders();
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getPeers } from "$lib/api/endpoints";
|
||||
import type { PeerView } from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
const BATCH_DELAY = 40;
|
||||
|
||||
function createPeers() {
|
||||
let map = $state<Record<number, PeerView>>({});
|
||||
const requested = new Set<number>();
|
||||
const pending = new Set<number>();
|
||||
let account: number | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function reset() {
|
||||
map = {};
|
||||
requested.clear();
|
||||
pending.clear();
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
timer = null;
|
||||
const ids = [...pending];
|
||||
pending.clear();
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
getPeers(ids)
|
||||
.then((peers) => {
|
||||
const next = { ...map };
|
||||
for (const peer of peers) {
|
||||
next[peer.peer_id] = peer;
|
||||
}
|
||||
map = next;
|
||||
})
|
||||
.catch(() => {
|
||||
for (const id of ids) {
|
||||
requested.delete(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reset,
|
||||
get(id: number): PeerView | undefined {
|
||||
return map[id];
|
||||
},
|
||||
ensure(ids: Iterable<number>) {
|
||||
if (accounts.selectedId !== account) {
|
||||
account = accounts.selectedId;
|
||||
reset();
|
||||
}
|
||||
for (const id of ids) {
|
||||
if (map[id] || requested.has(id)) {
|
||||
continue;
|
||||
}
|
||||
requested.add(id);
|
||||
pending.add(id);
|
||||
}
|
||||
if (pending.size > 0 && timer === null) {
|
||||
timer = setTimeout(flush, BATCH_DELAY);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const peers = createPeers();
|
||||
@@ -0,0 +1,67 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
type Preference = "light" | "dark" | "system";
|
||||
type Resolved = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "bg.theme";
|
||||
|
||||
function readPreference(): Preference {
|
||||
if (!browser) {
|
||||
return "system";
|
||||
}
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function prefersDark(): boolean {
|
||||
return browser && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function createTheme() {
|
||||
let preference = $state<Preference>(readPreference());
|
||||
let systemDark = $state(prefersDark());
|
||||
|
||||
function systemResolved(): Resolved {
|
||||
return systemDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
const resolved = $derived<Resolved>(
|
||||
preference === "system" ? systemResolved() : preference
|
||||
);
|
||||
|
||||
function watchSystem() {
|
||||
if (!browser) {
|
||||
return () => undefined;
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = (event: MediaQueryListEvent) => {
|
||||
systemDark = event.matches;
|
||||
};
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}
|
||||
|
||||
return {
|
||||
get preference() {
|
||||
return preference;
|
||||
},
|
||||
get resolved() {
|
||||
return resolved;
|
||||
},
|
||||
set(value: Preference) {
|
||||
preference = value;
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
}
|
||||
},
|
||||
toggle() {
|
||||
this.set(this.resolved === "dark" ? "light" : "dark");
|
||||
},
|
||||
watchSystem,
|
||||
};
|
||||
}
|
||||
|
||||
export const theme = createTheme();
|
||||
@@ -0,0 +1,47 @@
|
||||
type ToastType = "info" | "error" | "success";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 4000;
|
||||
|
||||
function createToasts() {
|
||||
let items = $state<Toast[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
function dismiss(id: number) {
|
||||
items = items.filter((toast) => toast.id !== id);
|
||||
}
|
||||
|
||||
function show(
|
||||
message: string,
|
||||
type: ToastType = "info",
|
||||
timeout = DEFAULT_TIMEOUT
|
||||
) {
|
||||
const id = nextId++;
|
||||
items = [...items, { id, type, message }];
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => dismiss(id), timeout);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
show,
|
||||
error(message: string) {
|
||||
return show(message, "error");
|
||||
},
|
||||
success(message: string) {
|
||||
return show(message, "success");
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
export const toasts = createToasts();
|
||||
@@ -0,0 +1,39 @@
|
||||
export type RightPanel =
|
||||
| "profile"
|
||||
| "search"
|
||||
| "versions"
|
||||
| "reactions"
|
||||
| "links"
|
||||
| "annotations"
|
||||
| "jobs"
|
||||
| "presence"
|
||||
| "stories"
|
||||
| "policy";
|
||||
|
||||
function createUi() {
|
||||
let rightPanel = $state<RightPanel | null>(null);
|
||||
let leftColumnOpen = $state(true);
|
||||
|
||||
return {
|
||||
get rightPanel() {
|
||||
return rightPanel;
|
||||
},
|
||||
get leftColumnOpen() {
|
||||
return leftColumnOpen;
|
||||
},
|
||||
openPanel(panel: RightPanel) {
|
||||
rightPanel = panel;
|
||||
},
|
||||
closePanel() {
|
||||
rightPanel = null;
|
||||
},
|
||||
toggleLeftColumn() {
|
||||
leftColumnOpen = !leftColumnOpen;
|
||||
},
|
||||
setLeftColumn(open: boolean) {
|
||||
leftColumnOpen = open;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ui = createUi();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
@use "variables";
|
||||
@use "spacing";
|
||||
@use "forms";
|
||||
@use "dark-theme";
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon::before {
|
||||
font-family: "icons" !important;
|
||||
speak: none;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
a:focus,
|
||||
[tabindex]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.custom-scroll {
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
|
||||
transition: scrollbar-color 0.3s ease;
|
||||
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 0.375rem;
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 1px rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
scrollbar-color: var(--color-scrollbar) transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-skeleton-background) 25%,
|
||||
var(--color-skeleton-foreground) 37%,
|
||||
var(--color-skeleton-background) 63%
|
||||
);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
html.theme-transition,
|
||||
html.theme-transition * {
|
||||
transition:
|
||||
background-color 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
color 0.25s ease !important;
|
||||
}
|
||||
|
||||
@keyframes ripple-animation {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ripple-container {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ripple-wave {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: scale(0);
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ripple-color, rgba(0, 0, 0, 0.08));
|
||||
animation: ripple-animation 700ms;
|
||||
}
|
||||
|
||||
.bg-menu-content {
|
||||
z-index: var(--z-portal-menu);
|
||||
min-width: 12rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background-compact-menu);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 0.25rem 1rem var(--color-default-shadow);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
html.theme-dark .bg-menu-content {
|
||||
border: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.bg-menu-item {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
|
||||
outline: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&[data-highlighted],
|
||||
&:hover {
|
||||
background-color: var(--color-background-compact-menu-hover);
|
||||
}
|
||||
|
||||
&[data-selected] {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bg-menu-separator {
|
||||
height: 1px;
|
||||
margin: 0.375rem 0;
|
||||
background-color: var(--color-background-menu-separator);
|
||||
}
|
||||
|
||||
$peer-colors: #e17076, #eda86c, #a695e7, #7bc862, #6ec9cb, #65aadd, #ee7aae;
|
||||
|
||||
@for $i from 0 through 6 {
|
||||
.peer-color-#{$i} {
|
||||
color: nth($peer-colors, $i + 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,271 @@
|
||||
// @optimization
|
||||
@mixin while-transition() {
|
||||
.Transition_slide:not(.Transition_slide-active) & {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin adapt-padding-to-scrollbar($padding, $forceSpace: 0px) {
|
||||
padding-inline-end: calc(max($padding - var(--scrollbar-width), $forceSpace));
|
||||
}
|
||||
|
||||
@mixin adapt-margin-to-scrollbar($margin, $forceSpace: 0px) {
|
||||
margin-inline-end: calc(max($margin - var(--scrollbar-width), $forceSpace));
|
||||
}
|
||||
|
||||
@mixin filter-outline($width: 0.125rem, $color) {
|
||||
filter:
|
||||
drop-shadow($width $width 0 $color)
|
||||
drop-shadow((-$width) $width 0 $color)
|
||||
drop-shadow($width (-$width) 0 $color)
|
||||
drop-shadow((-$width) (-$width) 0 $color);
|
||||
}
|
||||
|
||||
@mixin gradient-border-top($width, $cutout: 0px) {
|
||||
mask-image: linear-gradient(transparent $cutout, black $width);
|
||||
}
|
||||
|
||||
@mixin gradient-border-bottom($height, $cutout: 0px) {
|
||||
mask-image: linear-gradient(to top, transparent $cutout, black $height);
|
||||
}
|
||||
|
||||
@mixin gradient-border-horizontal($borderStart, $borderEnd) {
|
||||
mask-image: linear-gradient(to right, transparent, black $borderStart, black calc(100% - $borderEnd), transparent);
|
||||
}
|
||||
|
||||
@mixin gradient-border-left($indent, $cutout: 0px) {
|
||||
mask-image: linear-gradient(to right, transparent $cutout, black $indent);
|
||||
}
|
||||
|
||||
@mixin gradient-border-right($indent, $cutout: 0px) {
|
||||
mask-image: linear-gradient(to left, transparent $cutout, black $indent);
|
||||
}
|
||||
|
||||
@mixin gradient-border-top-bottom($top, $bottom) {
|
||||
mask-image: linear-gradient(transparent 0%, black $top, black calc(100% - $bottom), transparent 100%);
|
||||
}
|
||||
|
||||
@mixin peer-gradient($property, $colorCount) {
|
||||
--_accent-color-rgb: var(--color-accent-own-rgb);
|
||||
|
||||
html.theme-dark {
|
||||
--_accent-color-rgb: var(--color-text-rgb);
|
||||
}
|
||||
|
||||
@if $colorCount == 2 {
|
||||
#{$property}:
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(var(--_accent-color-rgb), 100%),
|
||||
rgb(var(--_accent-color-rgb), 100%) 5px,
|
||||
rgb(var(--_accent-color-rgb), 35%) 5px,
|
||||
rgb(var(--_accent-color-rgb), 35%) 10px
|
||||
);
|
||||
}
|
||||
|
||||
@else {
|
||||
#{$property}:
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(var(--_accent-color-rgb), 100%),
|
||||
rgb(var(--_accent-color-rgb), 100%) 5px,
|
||||
rgb(var(--_accent-color-rgb), 60%) 5px,
|
||||
rgb(var(--_accent-color-rgb), 60%) 10px,
|
||||
rgb(var(--_accent-color-rgb), 20%) 10px,
|
||||
rgb(var(--_accent-color-rgb), 20%) 15px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin reset-range() {
|
||||
input[type="range"] {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
}
|
||||
|
||||
&::-moz-range-track, &::-moz-range-progress {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin middle-header-pane {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateY(-100%);
|
||||
|
||||
width: 100%;
|
||||
height: 2.875rem;
|
||||
padding-top: 0.375rem;
|
||||
padding-right: max(0.5rem, env(safe-area-inset-right));
|
||||
padding-bottom: 0.375rem;
|
||||
padding-left: max(0.75rem, env(safe-area-inset-left));
|
||||
|
||||
background-color: var(--color-background);
|
||||
|
||||
transition: transform var(--slide-transition);
|
||||
|
||||
&::before {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
top: -0.1875rem;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
display: block;
|
||||
|
||||
height: 0.125rem;
|
||||
|
||||
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
|
||||
}
|
||||
|
||||
// Some panels might unmount without animation, so we provide same background above panel to make it less noticeable
|
||||
&::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: -100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
height: inherit;
|
||||
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin chat-list-pane {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateY(calc(-100% - 0.5rem)); // Include top margin to hide fully
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5625rem;
|
||||
border-radius: var(--border-radius-island);
|
||||
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
transition: transform var(--chat-transform-transition);
|
||||
|
||||
// Some panels might unmount without animation, so we provide same background above panel to make it less noticeable
|
||||
&::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: -100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
height: inherit;
|
||||
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) & {
|
||||
border: 1px solid var(--color-borders);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin side-panel-section {
|
||||
border-bottom: 0.625rem solid var(--color-background-secondary);
|
||||
background-color: var(--color-background);
|
||||
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin on-active-vt($type) {
|
||||
:global {
|
||||
.active-vt-#{$type} {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin with-vt-type($type) {
|
||||
:global(.active-vt-#{$type}) & {
|
||||
view-transition-name: var(--_vtn);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin chat-pattern-styles($path) {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
opacity: 0.4;
|
||||
background-image: url($path);
|
||||
background-repeat: repeat;
|
||||
background-position: center;
|
||||
background-size: 430px auto;
|
||||
mix-blend-mode: soft-light;
|
||||
|
||||
:global(html.theme-dark) & {
|
||||
opacity: 0.25;
|
||||
background: linear-gradient(145deg, #4f5bd5 0%, #962fbf 35%, #dd6cb9 65%, #fec496 100%) !important;
|
||||
background-image: none;
|
||||
mix-blend-mode: unset;
|
||||
|
||||
mask-image: url($path);
|
||||
mask-repeat: repeat;
|
||||
mask-position: center;
|
||||
mask-size: 430px auto;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin chat-pattern-background($path) {
|
||||
&::before {
|
||||
@include chat-pattern-styles($path);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin action-message-bg($isModule: false, $noBackground: false) {
|
||||
@if not $noBackground {
|
||||
background-color: var(--action-message-bg);
|
||||
}
|
||||
|
||||
@if $isModule {
|
||||
:global(body.with-message-blur) & {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@else {
|
||||
body.with-message-blur & {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/* stylelint-disable selector-max-type */
|
||||
/* stylelint-disable plugin/selector-tag-no-without-class */
|
||||
@layer reset {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: sans-serif;
|
||||
line-height: 1.15;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
article,
|
||||
aside,
|
||||
dialog,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
main,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body,
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[tabindex="-1"]:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
abbr[title],
|
||||
abbr[data-original-title] {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
dt,
|
||||
b,
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-links);
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
|
||||
-webkit-text-decoration-skip: objects;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #0056b3;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]),
|
||||
a:not([href]):not([tabindex]):hover,
|
||||
a:not([href]):not([tabindex]):focus {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]):focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
/* stylelint-disable-next-line @stylistic/max-line-length */
|
||||
font:
|
||||
0.9375rem / 1.25 "Courier",
|
||||
"Courier New",
|
||||
"Nimbus Mono L",
|
||||
"Courier 10 Pitch",
|
||||
"FreeMono",
|
||||
sans-serif-monospace,
|
||||
monospace;
|
||||
font-size-adjust: 0.5;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
dynamic-range-limit: standard;
|
||||
}
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a,
|
||||
area,
|
||||
button,
|
||||
[role="button"],
|
||||
input:not([type="range"]),
|
||||
label,
|
||||
select,
|
||||
summary,
|
||||
textarea {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
|
||||
color: #868e96;
|
||||
text-align: left;
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-appearance: button;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: 1px dotted;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
input[type="radio"],
|
||||
input[type="checkbox"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="datetime-local"],
|
||||
input[type="month"] {
|
||||
-webkit-appearance: listbox;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
font-size: 1.5rem;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: none;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@use "sass:map";
|
||||
|
||||
$spacer: 1rem !default;
|
||||
$spacers: () !default;
|
||||
$spacers: map.merge(
|
||||
(
|
||||
0: 0,
|
||||
1: (
|
||||
$spacer * 0.25,
|
||||
),
|
||||
2: (
|
||||
$spacer * 0.5,
|
||||
),
|
||||
3: $spacer,
|
||||
4: (
|
||||
$spacer * 1.5,
|
||||
),
|
||||
5: (
|
||||
$spacer * 2,
|
||||
),
|
||||
6: (
|
||||
$spacer * 3,
|
||||
),
|
||||
),
|
||||
$spacers
|
||||
);
|
||||
|
||||
// Margin and Padding
|
||||
|
||||
@each $prop, $abbrev in (margin: m, padding: p) {
|
||||
@each $size, $length in $spacers {
|
||||
.#{$abbrev}-#{$size} {
|
||||
#{$prop}: $length !important;
|
||||
}
|
||||
.#{$abbrev}t-#{$size},
|
||||
.#{$abbrev}y-#{$size} {
|
||||
#{$prop}-top: $length !important;
|
||||
}
|
||||
.#{$abbrev}r-#{$size},
|
||||
.#{$abbrev}x-#{$size} {
|
||||
#{$prop}-right: $length !important;
|
||||
}
|
||||
.#{$abbrev}b-#{$size},
|
||||
.#{$abbrev}y-#{$size} {
|
||||
#{$prop}-bottom: $length !important;
|
||||
}
|
||||
.#{$abbrev}l-#{$size},
|
||||
.#{$abbrev}x-#{$size} {
|
||||
#{$prop}-left: $length !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@forward "mixins";
|
||||
@@ -0,0 +1,359 @@
|
||||
@use "sass:color";
|
||||
|
||||
@function toRGB($color) {
|
||||
@return color.channel($color, "red") + ", " + color.channel($color, "green") + ", " + color.channel($color, "blue");
|
||||
}
|
||||
|
||||
@function blend-normal($foreground, $background) {
|
||||
$opacity: color.opacity($foreground);
|
||||
$background-opacity: color.opacity($background);
|
||||
|
||||
// calculate opacity
|
||||
/* stylelint-disable @stylistic/max-line-length */
|
||||
$bm-red: color.channel($foreground, "red") * $opacity + color.channel($background, "red") * $background-opacity * (1 - $opacity);
|
||||
$bm-green: color.channel($foreground, "green") * $opacity + color.channel($background, "green") * $background-opacity * (1 - $opacity);
|
||||
$bm-blue: color.channel($foreground, "blue") * $opacity + color.channel($background, "blue") * $background-opacity * (1 - $opacity);
|
||||
/* stylelint-enable @stylistic/max-line-length */
|
||||
|
||||
@return rgb($bm-red, $bm-green, $bm-blue);
|
||||
}
|
||||
|
||||
@layer variables {
|
||||
$color-primary: #3390ec;
|
||||
|
||||
$color-links: #3390ec;
|
||||
|
||||
$color-placeholders: #a2acb4;
|
||||
|
||||
$color-text-green: #4fae4e;
|
||||
$color-green: #00c73e;
|
||||
$color-light-green: #eeffde;
|
||||
|
||||
$color-error: #e53935;
|
||||
|
||||
$color-warning: #fb8c00;
|
||||
|
||||
$color-yellow: #fdd764;
|
||||
$color-orange: #d08a31;
|
||||
$color-light-coral: #d08a3133;
|
||||
|
||||
$color-white: #ffffff;
|
||||
$color-black: #000000;
|
||||
$color-dark-gray: #2e3939;
|
||||
$color-gray: #c4c9cc;
|
||||
$color-text-secondary: #707579;
|
||||
$color-text-secondary-apple: #8a8a90;
|
||||
$color-text-meta: #686c72;
|
||||
$color-text-meta-apple: #8c8c91;
|
||||
$color-borders: #dadce0;
|
||||
$color-dividers: #c8c6cc;
|
||||
$color-dividers-android: #E7E7E7;
|
||||
$color-item-hover: #f4f4f5;
|
||||
$color-item-active: #ededed;
|
||||
$color-chat-hover: #f4f4f5;
|
||||
$color-chat-active: #3390ec;
|
||||
$color-selection: #3993fb;
|
||||
|
||||
$color-message-reaction: #ebf3fd;
|
||||
$color-message-reaction-hover: #c5def9;
|
||||
$color-message-reaction-own: #cef0ba;
|
||||
$color-message-reaction-own-hover: #b5e0a4;
|
||||
$color-message-reaction-chosen-hover: #1a82ea;
|
||||
$color-message-reaction-chosen-hover-own: #3f9d4b;
|
||||
|
||||
$color-message-non-contact: #cceebf;
|
||||
|
||||
$color-message-story-mention-from: #4ef390;
|
||||
$color-message-story-mention-to: #74bcff;
|
||||
|
||||
:root {
|
||||
--color-background: #{$color-white};
|
||||
--color-background-compact-menu: #FFFFFFBB;
|
||||
--color-background-compact-menu-reactions: #FFFFFFEB;
|
||||
--color-background-compact-menu-hover: #000000B2;
|
||||
--color-background-menu-separator: #0000001a;
|
||||
--color-background-selected: #f4f4f5;
|
||||
--color-background-secondary: #f4f4f5;
|
||||
--color-background-secondary-accent: #e4e4e5;
|
||||
--color-background-sidebar: #E4E4E5;
|
||||
--color-background-own: #{$color-light-green};
|
||||
--color-background-own-selected: #{color.adjust($color-light-green, $lightness: -10%, $space: hsl)};
|
||||
--color-text: #{$color-black};
|
||||
--color-text-rgb: #{toRGB($color-black)};
|
||||
--color-text-lighter: #{$color-dark-gray};
|
||||
--color-text-secondary: #{$color-text-secondary};
|
||||
--color-icon-secondary: #{$color-text-secondary};
|
||||
--color-text-secondary-rgb: #{toRGB($color-text-secondary)};
|
||||
--color-text-secondary-apple: #{$color-text-secondary-apple};
|
||||
--color-text-meta: #{$color-text-meta};
|
||||
--color-text-meta-rgb: #{toRGB($color-text-meta)};
|
||||
--color-text-meta-colored: #{$color-text-green};
|
||||
--color-text-meta-apple: #{$color-text-meta-apple};
|
||||
--color-text-green: #{$color-text-green};
|
||||
--color-text-green-rgb: #{toRGB($color-text-green)};
|
||||
--color-borders: #{$color-borders};
|
||||
--color-borders-input: #{$color-borders};
|
||||
--color-borders-alternate: rgba(0, 0, 0, 0.1);
|
||||
--color-borders-read-story: #C4C9CC;
|
||||
--color-dividers: #{$color-dividers};
|
||||
--color-dividers-android: #{$color-dividers-android};
|
||||
--color-webpage-initial-background: #{$color-dark-gray};
|
||||
--color-interactive-active: var(--color-primary);
|
||||
--color-interactive-inactive: rgba(var(--color-text-secondary-rgb), 0.25);
|
||||
--color-interactive-buffered: rgba(var(--color-text-secondary-rgb), 0.25); // Overlays underlying inactive element
|
||||
--color-interactive-element-hover: rgba(var(--color-text-secondary-rgb), 0.08);
|
||||
--color-composer-button: #{$color-text-secondary}CC;
|
||||
--color-toast-background: #202020CC;
|
||||
|
||||
--color-voice-transcribe-button: #e8f3ff;
|
||||
--color-voice-transcribe-button-own: #cceebf;
|
||||
|
||||
--color-primary: #{$color-primary};
|
||||
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
|
||||
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};
|
||||
--color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
|
||||
--color-primary-opacity: rgba(var(--color-primary), 0.2);
|
||||
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
|
||||
--color-primary-tint: rgba(var(--color-primary), 0.1);
|
||||
--color-active: #{$color-green};
|
||||
--color-active-darker: #{color.mix($color-green, $color-black, 84%)};
|
||||
--color-success: #{$color-green};
|
||||
|
||||
--accent-color: var(--color-primary);
|
||||
--accent-background-color: var(--color-primary-tint);
|
||||
--accent-background-active-color: var(--color-primary-opacity-hover);
|
||||
|
||||
--color-green: #{$color-green};
|
||||
--color-green-darker: #{color.mix($color-green, $color-black, 84%)};
|
||||
--color-green-rgb: #{toRGB($color-green)};
|
||||
|
||||
--color-error: #{$color-error};
|
||||
--color-error-shade: #{color.mix($color-error, $color-black, 92%)};
|
||||
--color-error-rgb: #{toRGB($color-error)};
|
||||
|
||||
--color-warning: #{$color-warning};
|
||||
|
||||
--color-yellow: #{$color-yellow};
|
||||
|
||||
--color-orange: #{$color-orange};
|
||||
--color-light-coral: #{$color-light-coral};
|
||||
|
||||
--color-links: #{$color-links};
|
||||
|
||||
--color-own-links: #{$color-white};
|
||||
|
||||
--color-placeholders: #{$color-placeholders};
|
||||
|
||||
--color-list-icon: #{$color-white};
|
||||
|
||||
--color-code: #4a729a;
|
||||
--color-code-bg: #{rgba($color-text-secondary, 0.08)};
|
||||
--color-code-own: #3c7940;
|
||||
--color-code-own-bg: #{rgba($color-text-secondary, 0.08)};
|
||||
|
||||
--color-accent-own: #{$color-text-green};
|
||||
--color-accent-own-rgb: #{toRGB($color-text-green)};
|
||||
--color-message-meta-own: #{$color-text-green};
|
||||
|
||||
--color-message-reaction: #{$color-message-reaction};
|
||||
--color-message-reaction-hover: #{$color-message-reaction-hover};
|
||||
--color-message-reaction-own: #{$color-message-reaction-own};
|
||||
--color-message-reaction-hover-own: #{$color-message-reaction-own-hover};
|
||||
--color-message-reaction-chosen-hover: #{$color-message-reaction-chosen-hover};
|
||||
--color-message-reaction-chosen-hover-own: #{$color-message-reaction-chosen-hover-own};
|
||||
|
||||
--color-message-non-contact: #{$color-message-non-contact};
|
||||
|
||||
--color-message-story-mention-from: #{$color-message-story-mention-from};
|
||||
--color-message-story-mention-to: #{$color-message-story-mention-to};
|
||||
|
||||
--color-reply-hover: #{blend-normal(rgba($color-text-secondary, 0.08), $color-white)};
|
||||
--color-reply-active: #{blend-normal(rgba($color-text-secondary, 0.16), $color-white)};
|
||||
--color-reply-own-hover: #{blend-normal(rgba($color-text-green, 0.12), $color-light-green)};
|
||||
--color-reply-own-active: #{blend-normal(rgba($color-text-green, 0.24), $color-light-green)};
|
||||
|
||||
--color-background-own-apple: #dcf8c5;
|
||||
--color-reply-own-hover-apple: #cbefb7;
|
||||
--color-reply-own-active-apple: #bae6a8;
|
||||
|
||||
--color-white: #{$color-white};
|
||||
--color-gray: #{$color-gray};
|
||||
|
||||
--color-chat-username: #3C7EB0;
|
||||
--color-chat-hover: #{$color-chat-hover};
|
||||
--color-chat-active: #{$color-chat-active};
|
||||
--color-item-hover: #{$color-item-hover};
|
||||
--color-item-active: #{$color-item-active};
|
||||
|
||||
--color-selection-highlight: #{$color-selection};
|
||||
--color-selection-highlight-emoji: rgba(#{toRGB($color-selection)}, 0.7);
|
||||
|
||||
--color-avatar-story-unread-from: #34c578;
|
||||
--color-avatar-story-unread-to: #3ca3f3;
|
||||
--color-avatar-story-friend-unread-from: #c9eb38;
|
||||
--color-avatar-story-friend-unread-to: #09c167;
|
||||
|
||||
--color-default-shadow: #72727240;
|
||||
--color-light-shadow: #7272722b;
|
||||
|
||||
--color-skeleton-background: rgba(33, 33, 33, 0.15);
|
||||
--color-skeleton-foreground: rgba(232, 232, 232, 0.2);
|
||||
|
||||
--color-scrollbar: rgba(90, 90, 90, 0.3);
|
||||
--color-scrollbar-code: rgba(200, 200, 200, 0.3);
|
||||
|
||||
--color-telegram-blue: #{$color-primary};
|
||||
|
||||
--color-forum-hover-unread-topic: #e9e9e9;
|
||||
--color-forum-hover-unread-topic-hover: #dcdcdc;
|
||||
|
||||
--color-deleted-account: #9eaab5;
|
||||
--color-archive: #9eaab5;
|
||||
--stars-gradient: linear-gradient(90deg, #FFAA00 0%, #FFCD3A 100%);
|
||||
|
||||
--color-stars: #FFAA00;
|
||||
--color-heart: #ff3c32;
|
||||
|
||||
--color-gift-uncommon: #40A920;
|
||||
--color-gift-uncommon-bg: rgba(64, 169, 32, 0.15);
|
||||
--color-gift-rare: #11AABE;
|
||||
--color-gift-rare-bg: rgba(17, 170, 190, 0.15);
|
||||
--color-gift-epic: #955CDB;
|
||||
--color-gift-epic-bg: rgba(149, 92, 219, 0.15);
|
||||
--color-gift-legendary: #BF7600;
|
||||
--color-gift-legendary-bg: rgba(191, 118, 0, 0.15);
|
||||
|
||||
--color-negative-progress: #CE4C47;
|
||||
|
||||
--vh: 1vh;
|
||||
|
||||
--border-radius-button: 1rem;
|
||||
--border-radius-button-tiny: 0.875rem;
|
||||
--border-radius-modal: 2rem;
|
||||
--border-radius-toast: 1rem;
|
||||
--border-radius-island: 1.5rem;
|
||||
--border-radius-default: 1rem;
|
||||
--border-radius-default-small: 0.625rem;
|
||||
--border-radius-default-tiny: 0.375rem;
|
||||
--border-radius-messages: 0.9375rem;
|
||||
--border-radius-messages-small: 0.375rem;
|
||||
--border-radius-forum-avatar: 33.3333%;
|
||||
--messages-container-width: 45.5rem;
|
||||
--right-column-width: 26.5rem;
|
||||
--folders-sidebar-width: 5rem;
|
||||
--window-controls-width: 0rem;
|
||||
--header-height: 3.5rem;
|
||||
--emoji-size: 1.25em;
|
||||
--custom-emoji-size: var(--emoji-size);
|
||||
--custom-emoji-border-radius: 0;
|
||||
|
||||
--symbol-menu-width: 24rem;
|
||||
--symbol-menu-height: 22.375rem;
|
||||
--symbol-menu-footer-height: 3rem;
|
||||
|
||||
--scrollbar-width: 0;
|
||||
|
||||
--z-overlay-effects: 12000;
|
||||
--z-modal-confirm: 10500;
|
||||
--z-reaction-picker: 10200;
|
||||
--z-portal-menu: 10000;
|
||||
--z-symbol-menu-modal: 5000;
|
||||
--z-lock-screen: 3000;
|
||||
--z-ui-loader-mask: 2000;
|
||||
--z-notification: 1700;
|
||||
--z-confetti: 1600;
|
||||
--z-story-viewer: 1150;
|
||||
--z-reaction-interaction-effect: 1100;
|
||||
--z-right-column: 900;
|
||||
--z-right-column-menu: 950;
|
||||
--z-header-menu: 990;
|
||||
--z-header-menu-backdrop: 980;
|
||||
--z-modal: 1510;
|
||||
--z-modal-menu: 1600;
|
||||
--z-resize-grip: 1000;
|
||||
--z-media-viewer: 1500;
|
||||
--z-modal-low-priority: 1400;
|
||||
--z-video-player-controls: 3;
|
||||
--z-drop-area: 55;
|
||||
--z-animation-fade: 50;
|
||||
--z-menu-bubble: 21;
|
||||
--z-menu-backdrop: 20;
|
||||
--z-message-effect: 15;
|
||||
--z-message-highlighted: 14;
|
||||
--z-forum-panel: 13;
|
||||
--z-message-context-menu: 13;
|
||||
--z-scroll-down-button: 12;
|
||||
--z-local-search: 12;
|
||||
--z-left-header: 11;
|
||||
--z-middle-header: 11;
|
||||
--z-middle-footer: 11;
|
||||
--z-scroll-notch: 10;
|
||||
--z-story-ribbon: 10;
|
||||
--z-country-code-input-group: 10;
|
||||
--z-message-select-control: 9;
|
||||
--z-message-select-area: 8;
|
||||
--z-sticky-date: 9;
|
||||
--z-register-add-avatar: 5;
|
||||
--z-media-viewer-head: 3;
|
||||
--z-symbol-menu-mobile: calc(var(--z-story-viewer) + 1);
|
||||
--z-resize-handle: 2;
|
||||
--z-below: -1;
|
||||
--z-chat-ripple: 6;
|
||||
--z-chat-float-button: calc(var(--z-chat-ripple) + 1);
|
||||
|
||||
--spinner-white-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iI2ZmZmZmZiIvPjwvc3ZnPg==);
|
||||
--spinner-white-thin-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTEyIDIzQzUuOSAyMyAxIDE4LjEgMSAxMlM1LjkgMSAxMiAxVjBDNS40IDAgMCA1LjQgMCAxMnM1LjQgMTIgMTIgMTIgMTItNS40IDEyLTEyaC0xYzAgNi4xLTQuOSAxMS0xMSAxMXoiLz48L3N2Zz4=);
|
||||
--spinner-blue-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzRlYTRmNiIvPjwvc3ZnPg==);
|
||||
--spinner-dark-blue-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzgzNzhEQiIvPjwvc3ZnPg==);
|
||||
--spinner-black-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzJlMzkzOSIvPjwvc3ZnPg==);
|
||||
--spinner-green-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzRmYWU0ZSIvPjwvc3ZnPg==);
|
||||
--spinner-gray-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzcwNzU3OSIvPjwvc3ZnPg==);
|
||||
--spinner-yellow-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iI0ZERDc2NCIvPjwvc3ZnPg==);
|
||||
|
||||
--premium-gradient: linear-gradient(84.4deg, #6C93FF -4.85%, #976FFF 51.72%, #DF69D1 110.7%);
|
||||
|
||||
--layer-blackout-opacity: 0.1;
|
||||
|
||||
--layer-transition: 300ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||
--layer-transition-behind: 300ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||
--slide-transition: 300ms cubic-bezier(0.25, 1, 0.5, 1);
|
||||
--select-transition: 200ms ease-out;
|
||||
--chat-transform-transition: 0.2s ease-out;
|
||||
|
||||
--safe-area-top: env(safe-area-inset-top);
|
||||
--safe-area-right: env(safe-area-inset-right);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-left: env(safe-area-inset-left);
|
||||
|
||||
--picker-title-shift: 1rem;
|
||||
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
|
||||
--middle-header-panes-height: 0px;
|
||||
|
||||
body.is-ios {
|
||||
--layer-transition: 650ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--layer-transition-behind: 650ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||
--slide-transition: 450ms cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
body.is-android {
|
||||
--slide-transition: 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 1276px) and (max-width: 1920px) {
|
||||
--right-column-width: 25vw;
|
||||
}
|
||||
|
||||
@media (min-width: 1921px) {
|
||||
--messages-container-width: 50vw;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
--right-column-width: 100vw;
|
||||
--symbol-menu-width: 100vw;
|
||||
--symbol-menu-height: 17.6875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user