Compare commits
3 Commits
f0afb7ec5b
...
2465bcd184
| Author | SHA1 | Date | |
|---|---|---|---|
| 2465bcd184 | |||
| ed469ba8dd | |||
| 75425d1bee |
@@ -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,42 @@
|
|||||||
|
"""custom emoji documents
|
||||||
|
|
||||||
|
Revision ID: c4e8a1f7d9b2
|
||||||
|
Revises: a9c3e7f1d2b4
|
||||||
|
Create Date: 2026-05-31 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "c4e8a1f7d9b2"
|
||||||
|
down_revision: str | None = "a9c3e7f1d2b4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"custom_emoji",
|
||||||
|
sa.Column("custom_emoji_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("storage_key", sa.String(), nullable=True),
|
||||||
|
sa.Column("file_size", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("mime", sa.String(), nullable=True),
|
||||||
|
sa.Column("kind", sa.String(), nullable=True),
|
||||||
|
sa.Column("downloaded", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("raw", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"first_seen_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("custom_emoji_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("custom_emoji")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""dialogs
|
||||||
|
|
||||||
|
Revision ID: d5f9b2c8e3a1
|
||||||
|
Revises: c4e8a1f7d9b2
|
||||||
|
Create Date: 2026-05-31 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "d5f9b2c8e3a1"
|
||||||
|
down_revision: str | None = "c4e8a1f7d9b2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"dialogs",
|
||||||
|
sa.Column("account_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("chat_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("account_id", "chat_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("dialogs")
|
||||||
@@ -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,25 +1,35 @@
|
|||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka, setup_dishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka, setup_dishka
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastmcp.utilities.lifespan import combine_lifespans
|
from fastmcp.utilities.lifespan import combine_lifespans
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
|
|
||||||
from api.auth import BearerAuthMiddleware
|
from api.auth import BearerAuthMiddleware
|
||||||
from api.mcp.server import mcp
|
from api.mcp.server import mcp
|
||||||
|
from api.realtime import hub
|
||||||
from api.routers import (
|
from api.routers import (
|
||||||
|
accounts,
|
||||||
|
analytics,
|
||||||
annotations,
|
annotations,
|
||||||
|
avatars,
|
||||||
backfill,
|
backfill,
|
||||||
chats,
|
chats,
|
||||||
|
custom_emoji,
|
||||||
|
events,
|
||||||
folders,
|
folders,
|
||||||
media,
|
media,
|
||||||
peers,
|
peers,
|
||||||
policy,
|
policy,
|
||||||
presence,
|
presence,
|
||||||
|
profile,
|
||||||
search,
|
search,
|
||||||
social,
|
social,
|
||||||
|
stories,
|
||||||
watches,
|
watches,
|
||||||
)
|
)
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
@@ -35,7 +45,10 @@ mcp_app = mcp.http_app(path="/")
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
|
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
|
||||||
|
pool = await container.get(asyncpg.Pool)
|
||||||
|
await hub.start(pool)
|
||||||
yield
|
yield
|
||||||
|
await hub.stop()
|
||||||
await app_.state.dishka_container.close()
|
await app_.state.dishka_container.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -54,20 +67,39 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
|
|||||||
return {"db": db_ok, "timescaledb": bool(timescale_ok)}
|
return {"db": db_ok, "timescaledb": bool(timescale_ok)}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(accounts.router)
|
||||||
|
app.include_router(analytics.router)
|
||||||
app.include_router(policy.router)
|
app.include_router(policy.router)
|
||||||
app.include_router(folders.router)
|
app.include_router(folders.router)
|
||||||
app.include_router(backfill.router)
|
app.include_router(backfill.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
app.include_router(chats.router)
|
app.include_router(chats.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
|
app.include_router(avatars.router)
|
||||||
|
app.include_router(custom_emoji.router)
|
||||||
app.include_router(social.router)
|
app.include_router(social.router)
|
||||||
app.include_router(presence.router)
|
app.include_router(presence.router)
|
||||||
|
app.include_router(stories.router)
|
||||||
|
app.include_router(profile.router)
|
||||||
|
app.include_router(events.router)
|
||||||
app.include_router(peers.router)
|
app.include_router(peers.router)
|
||||||
app.include_router(annotations.router)
|
app.include_router(annotations.router)
|
||||||
app.include_router(watches.router)
|
app.include_router(watches.router)
|
||||||
|
|
||||||
app.mount("/mcp", mcp_app)
|
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)
|
app.add_middleware(BearerAuthMiddleware, token=_token)
|
||||||
|
|
||||||
setup_dishka(container, app)
|
setup_dishka(container, app)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.env import env
|
||||||
|
from utils.events import BG_EVENTS_CHANNEL
|
||||||
|
from utils.read import chats as chats_read
|
||||||
|
from utils.read import presence as presence_read
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
QUEUE_MAXSIZE = 256
|
||||||
|
|
||||||
|
|
||||||
|
class Subscriber:
|
||||||
|
def __init__(self, account_id: int, chat_id: int | None) -> None:
|
||||||
|
self.account_id = account_id
|
||||||
|
self.chat_id = chat_id
|
||||||
|
self.queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=QUEUE_MAXSIZE)
|
||||||
|
|
||||||
|
|
||||||
|
class EventHub:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._subscribers: set[Subscriber] = set()
|
||||||
|
self._pool: asyncpg.Pool | None = None
|
||||||
|
self._conn: asyncpg.Connection | None = None
|
||||||
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
|
||||||
|
sub = Subscriber(account_id, chat_id)
|
||||||
|
self._subscribers.add(sub)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
def unsubscribe(self, sub: Subscriber) -> None:
|
||||||
|
self._subscribers.discard(sub)
|
||||||
|
|
||||||
|
async def start(self, pool: asyncpg.Pool) -> None:
|
||||||
|
self._pool = pool
|
||||||
|
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||||
|
await conn.add_listener(BG_EVENTS_CHANNEL, self._on_notify)
|
||||||
|
self._conn = conn
|
||||||
|
logger.info("Realtime hub listening on %s", BG_EVENTS_CHANNEL)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
for task in self._tasks:
|
||||||
|
task.cancel()
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
def _on_notify(
|
||||||
|
self, _conn: asyncpg.Connection, _pid: int, _channel: str, payload: str
|
||||||
|
) -> None:
|
||||||
|
task = asyncio.create_task(self._dispatch(payload))
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
|
async def _dispatch(self, payload: str) -> None:
|
||||||
|
try:
|
||||||
|
event = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
account_id = event.get("account_id")
|
||||||
|
chat_id = event.get("chat_id")
|
||||||
|
targets = [
|
||||||
|
sub
|
||||||
|
for sub in self._subscribers
|
||||||
|
if sub.account_id == account_id
|
||||||
|
and (sub.chat_id is None or sub.chat_id == chat_id)
|
||||||
|
]
|
||||||
|
if not targets:
|
||||||
|
return
|
||||||
|
frame = await self._build_frame(event)
|
||||||
|
if frame is None:
|
||||||
|
return
|
||||||
|
for sub in targets:
|
||||||
|
try:
|
||||||
|
sub.queue.put_nowait(frame)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
logger.warning("Dropping event for slow subscriber")
|
||||||
|
|
||||||
|
async def _build_frame( # noqa: PLR0911
|
||||||
|
self, event: dict[str, Any]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if self._pool is None:
|
||||||
|
return None
|
||||||
|
kind = event.get("kind")
|
||||||
|
account_id = event["account_id"]
|
||||||
|
if kind in {"message", "edit", "reaction"}:
|
||||||
|
view = await chats_read.get_message(
|
||||||
|
self._pool, account_id, event["chat_id"], event["message_id"]
|
||||||
|
)
|
||||||
|
if view is None:
|
||||||
|
return None
|
||||||
|
return {"type": kind, "message": view.model_dump(mode="json")}
|
||||||
|
if kind == "delete":
|
||||||
|
return {
|
||||||
|
"type": "delete",
|
||||||
|
"chat_id": event.get("chat_id"),
|
||||||
|
"message_ids": event.get("message_ids", []),
|
||||||
|
}
|
||||||
|
if kind == "presence":
|
||||||
|
sample = await presence_read.current_presence(
|
||||||
|
self._pool, account_id, event["chat_id"]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "presence",
|
||||||
|
"peer_id": event["chat_id"],
|
||||||
|
"sample": sample.model_dump(mode="json") if sample else None,
|
||||||
|
}
|
||||||
|
if kind == "receipt":
|
||||||
|
return {
|
||||||
|
"type": "receipt",
|
||||||
|
"chat_id": event["chat_id"],
|
||||||
|
"read_up_to": event["message_id"],
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
hub = EventHub()
|
||||||
@@ -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,30 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from utils.read import analytics
|
||||||
|
from utils.read.models import ResponseStats, VolumeBucket
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
|
||||||
|
|
||||||
|
AccountId = Annotated[int, Query()]
|
||||||
|
ChatId = Annotated[int, Query()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/volume")
|
||||||
|
async def volume(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: AccountId,
|
||||||
|
chat_id: ChatId,
|
||||||
|
days: Annotated[int, Query()] = 90,
|
||||||
|
) -> list[VolumeBucket]:
|
||||||
|
return await analytics.message_volume(pool, account_id, chat_id, days=days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/response-time")
|
||||||
|
async def response_time(
|
||||||
|
pool: FromDishka[asyncpg.Pool], account_id: AccountId, chat_id: ChatId
|
||||||
|
) -> ResponseStats:
|
||||||
|
return await analytics.response_stats(pool, account_id, chat_id)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 avatar_by_unique_id, avatar_history, current_avatar
|
||||||
|
from utils.read.models import AvatarHistoryView
|
||||||
|
from utils.storage import ContentAddressedStorage
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/avatars", tags=["avatars"], route_class=DishkaRoute)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{owner_kind}/{owner_id}/history")
|
||||||
|
async def serve_avatar_history(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
owner_kind: str, # noqa: ARG001
|
||||||
|
owner_id: int,
|
||||||
|
account_id: Annotated[int, Query()],
|
||||||
|
) -> list[AvatarHistoryView]:
|
||||||
|
return await avatar_history(pool, account_id, owner_id)
|
||||||
|
|
||||||
|
|
||||||
|
@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()],
|
||||||
|
unique_id: Annotated[str | None, Query()] = None,
|
||||||
|
) -> FileResponse:
|
||||||
|
avatar = (
|
||||||
|
await avatar_by_unique_id(pool, account_id, owner_id, unique_id)
|
||||||
|
if unique_id is not None
|
||||||
|
else 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"
|
||||||
|
)
|
||||||
@@ -24,6 +24,10 @@ class FetchMediaRequest(BaseModel):
|
|||||||
message_id: int
|
message_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class SyncDialogsRequest(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
|
||||||
|
|
||||||
class EnqueueResponse(BaseModel):
|
class EnqueueResponse(BaseModel):
|
||||||
job_id: int
|
job_id: int
|
||||||
|
|
||||||
@@ -78,6 +82,14 @@ async def enqueue_fetch_media(
|
|||||||
return EnqueueResponse(job_id=job_id)
|
return EnqueueResponse(job_id=job_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dialogs/sync", status_code=201)
|
||||||
|
async def enqueue_sync_dialogs(
|
||||||
|
pool: FromDishka[asyncpg.Pool], body: SyncDialogsRequest
|
||||||
|
) -> EnqueueResponse:
|
||||||
|
job_id = await enqueue(pool, body.account_id, "sync_dialogs", {})
|
||||||
|
return EnqueueResponse(job_id=job_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs")
|
@router.get("/jobs")
|
||||||
async def list_jobs(
|
async def list_jobs(
|
||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from typing import Annotated
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from utils.jobs import enqueue
|
||||||
from utils.read import chats
|
from utils.read import chats
|
||||||
from utils.read.models import (
|
from utils.read.models import (
|
||||||
DEFAULT_LIMIT,
|
DEFAULT_LIMIT,
|
||||||
@@ -11,10 +13,17 @@ from utils.read.models import (
|
|||||||
MessageVersionView,
|
MessageVersionView,
|
||||||
MessageView,
|
MessageView,
|
||||||
Page,
|
Page,
|
||||||
|
PinnedView,
|
||||||
)
|
)
|
||||||
|
from utils.read.pinned import get_pinned
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["chats"], route_class=DishkaRoute)
|
router = APIRouter(prefix="/api", tags=["chats"], route_class=DishkaRoute)
|
||||||
|
|
||||||
|
|
||||||
|
class EnrichRequest(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
|
||||||
|
|
||||||
AccountId = Annotated[int, Query()]
|
AccountId = Annotated[int, Query()]
|
||||||
Limit = Annotated[int, Query()]
|
Limit = Annotated[int, Query()]
|
||||||
Offset = Annotated[int, Query()]
|
Offset = Annotated[int, Query()]
|
||||||
@@ -38,6 +47,8 @@ async def chat_history(
|
|||||||
limit: Limit = DEFAULT_LIMIT,
|
limit: Limit = DEFAULT_LIMIT,
|
||||||
offset: Offset = 0,
|
offset: Offset = 0,
|
||||||
include_deleted: Annotated[bool, Query()] = True,
|
include_deleted: Annotated[bool, Query()] = True,
|
||||||
|
before_id: Annotated[int | None, Query()] = None,
|
||||||
|
after_id: Annotated[int | None, Query()] = None,
|
||||||
) -> list[MessageView]:
|
) -> list[MessageView]:
|
||||||
return await chats.get_chat_history(
|
return await chats.get_chat_history(
|
||||||
pool,
|
pool,
|
||||||
@@ -45,9 +56,26 @@ async def chat_history(
|
|||||||
chat_id,
|
chat_id,
|
||||||
Page(limit=limit, offset=offset),
|
Page(limit=limit, offset=offset),
|
||||||
include_deleted=include_deleted,
|
include_deleted=include_deleted,
|
||||||
|
before_id=before_id,
|
||||||
|
after_id=after_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chats/{chat_id}/pinned")
|
||||||
|
async def chat_pinned(
|
||||||
|
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
|
||||||
|
) -> PinnedView | None:
|
||||||
|
return await get_pinned(pool, account_id, chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
@router.get("/chats/{chat_id}/messages/{message_id}/versions")
|
||||||
async def message_versions(
|
async def message_versions(
|
||||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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.custom_emoji import current_custom_emoji
|
||||||
|
from utils.storage import ContentAddressedStorage
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/custom-emoji", tags=["custom-emoji"], route_class=DishkaRoute
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{custom_emoji_id}")
|
||||||
|
async def serve_custom_emoji(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
storage: FromDishka[ContentAddressedStorage],
|
||||||
|
custom_emoji_id: int,
|
||||||
|
account_id: Annotated[int, Query()],
|
||||||
|
) -> FileResponse:
|
||||||
|
emoji = await current_custom_emoji(pool, custom_emoji_id)
|
||||||
|
if emoji is None or not emoji.downloaded or emoji.storage_key is None:
|
||||||
|
await enqueue(
|
||||||
|
pool, account_id, "fetch_custom_emoji", {"custom_emoji_id": custom_emoji_id}
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="custom emoji not downloaded; fetching"
|
||||||
|
)
|
||||||
|
return FileResponse(
|
||||||
|
storage.url(emoji.storage_key),
|
||||||
|
media_type=emoji.mime or "application/octet-stream",
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from api.realtime import Subscriber, hub
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||||
|
|
||||||
|
HEARTBEAT_SECONDS = 15
|
||||||
|
|
||||||
|
AccountId = Annotated[int, Query()]
|
||||||
|
ChatId = Annotated[int | None, Query()]
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream(sub: Subscriber) -> AsyncGenerator[str]:
|
||||||
|
try:
|
||||||
|
yield ": connected\n\n"
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
frame = await asyncio.wait_for(
|
||||||
|
sub.queue.get(), timeout=HEARTBEAT_SECONDS
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
continue
|
||||||
|
yield f"event: {frame['type']}\ndata: {json.dumps(frame)}\n\n"
|
||||||
|
finally:
|
||||||
|
hub.unsubscribe(sub)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def events(account_id: AccountId, chat_id: ChatId = None) -> StreamingResponse:
|
||||||
|
sub = hub.subscribe(account_id, chat_id)
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream(sub),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from utils.read.media import get_media
|
from utils.read.media import (
|
||||||
from utils.read.models import MediaView
|
get_media,
|
||||||
|
get_media_version,
|
||||||
|
get_media_versions,
|
||||||
|
get_message_media,
|
||||||
|
)
|
||||||
|
from utils.read.models import MediaVersionView, MediaView
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
|
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
|
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}")
|
@router.get("/{media_id}")
|
||||||
async def serve_media(
|
async def serve_media(
|
||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from utils.read import peers
|
from utils.read import peers
|
||||||
from utils.read.models import DEFAULT_LIMIT, Page, PeerHistoryView, PeerView, StoryView
|
from utils.read.models import PeerHistoryView, PeerView
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
|
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
|
||||||
|
|
||||||
AccountId = Annotated[int, Query()]
|
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}")
|
@router.get("/peers/{peer_id}")
|
||||||
async def get_peer(
|
async def get_peer(
|
||||||
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
|
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
|
||||||
@@ -27,16 +35,3 @@ async def peer_history(
|
|||||||
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
|
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
|
||||||
) -> list[PeerHistoryView]:
|
) -> list[PeerHistoryView]:
|
||||||
return await peers.get_peer_history(pool, account_id, peer_id)
|
return await peers.get_peer_history(pool, account_id, peer_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stories")
|
|
||||||
async def stories(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
account_id: AccountId,
|
|
||||||
peer_id: Annotated[int | None, Query()] = None,
|
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
|
||||||
offset: Annotated[int, Query()] = 0,
|
|
||||||
) -> list[StoryView]:
|
|
||||||
return await peers.get_stories(
|
|
||||||
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ async def presence_history(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current")
|
||||||
|
async def current_presence(
|
||||||
|
pool: FromDishka[asyncpg.Pool], account_id: AccountId, peer_id: PeerId
|
||||||
|
) -> PresenceSample | None:
|
||||||
|
return await presence.current_presence(pool, account_id, peer_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hourly")
|
@router.get("/hourly")
|
||||||
async def presence_hourly(
|
async def presence_hourly(
|
||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from utils.read import profile
|
||||||
|
from utils.read.models import (
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
ChatLinkView,
|
||||||
|
DayCount,
|
||||||
|
MediaView,
|
||||||
|
MessageAt,
|
||||||
|
Page,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/chats/{chat_id}", tags=["profile"], route_class=DishkaRoute
|
||||||
|
)
|
||||||
|
|
||||||
|
AccountId = Annotated[int, Query()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/media")
|
||||||
|
async def chat_media(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
chat_id: int,
|
||||||
|
account_id: AccountId,
|
||||||
|
kinds: Annotated[str, Query()],
|
||||||
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
|
offset: Annotated[int, Query()] = 0,
|
||||||
|
) -> list[MediaView]:
|
||||||
|
parsed = [part for part in kinds.split(",") if part.strip()]
|
||||||
|
return await profile.chat_media(
|
||||||
|
pool, account_id, chat_id, parsed, Page(limit=limit, offset=offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links")
|
||||||
|
async def chat_links(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
chat_id: int,
|
||||||
|
account_id: AccountId,
|
||||||
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
|
offset: Annotated[int, Query()] = 0,
|
||||||
|
) -> list[ChatLinkView]:
|
||||||
|
return await profile.chat_links(
|
||||||
|
pool, account_id, chat_id, Page(limit=limit, offset=offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendar")
|
||||||
|
async def chat_calendar(
|
||||||
|
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
|
||||||
|
) -> list[DayCount]:
|
||||||
|
return await profile.daily_counts(pool, account_id, chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/message-at")
|
||||||
|
async def message_at(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
chat_id: int,
|
||||||
|
account_id: AccountId,
|
||||||
|
date: Annotated[datetime, Query()],
|
||||||
|
) -> MessageAt:
|
||||||
|
found = await profile.first_message_on_day(pool, account_id, chat_id, date)
|
||||||
|
if found is None:
|
||||||
|
raise HTTPException(status_code=404, detail="no message on day")
|
||||||
|
return found
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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.read import peers
|
||||||
|
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
|
||||||
|
from utils.storage import ContentAddressedStorage
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["stories"], route_class=DishkaRoute)
|
||||||
|
|
||||||
|
AccountId = Annotated[int, Query()]
|
||||||
|
|
||||||
|
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stories")
|
||||||
|
async def list_stories(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: AccountId,
|
||||||
|
peer_id: Annotated[int | None, Query()] = None,
|
||||||
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
|
offset: Annotated[int, Query()] = 0,
|
||||||
|
) -> list[StoryView]:
|
||||||
|
return await peers.get_stories(
|
||||||
|
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stories/{peer_id}/{story_id}/media")
|
||||||
|
async def serve_story_media(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
storage: FromDishka[ContentAddressedStorage],
|
||||||
|
peer_id: int,
|
||||||
|
story_id: int,
|
||||||
|
account_id: AccountId,
|
||||||
|
) -> FileResponse:
|
||||||
|
story = await peers.get_story(pool, account_id, peer_id, story_id)
|
||||||
|
if story is None:
|
||||||
|
raise HTTPException(status_code=404, detail="story not found")
|
||||||
|
if not story.downloaded or story.storage_key is None:
|
||||||
|
raise HTTPException(status_code=409, detail="story media not downloaded")
|
||||||
|
return FileResponse(
|
||||||
|
storage.url(story.storage_key),
|
||||||
|
media_type=_STORY_MIME.get(story.media_kind or "", "application/octet-stream"),
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ from pyrogram.types import Message
|
|||||||
from userbot import PyroClient
|
from userbot import PyroClient
|
||||||
from userbot.modules.capture import repository
|
from userbot.modules.capture import repository
|
||||||
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
|
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
|
||||||
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
|
|
||||||
@PyroClient.on_deleted_messages()
|
@PyroClient.on_deleted_messages()
|
||||||
@@ -21,8 +22,12 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
|
|||||||
channels.setdefault(chat_id, []).append(message.id)
|
channels.setdefault(chat_id, []).append(message.id)
|
||||||
if box:
|
if box:
|
||||||
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
|
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
|
||||||
|
await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box)
|
||||||
for chat_id, ids in channels.items():
|
for chat_id, ids in channels.items():
|
||||||
await repository.mark_deleted_channel(ctx.pool, ctx.account_id, chat_id, ids)
|
await repository.mark_deleted_channel(ctx.pool, ctx.account_id, chat_id, ids)
|
||||||
|
await notify_bg_event(
|
||||||
|
ctx.pool, "delete", ctx.account_id, chat_id=chat_id, message_ids=ids
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_deleted_messages.handlers
|
handlers = on_deleted_messages.handlers
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from userbot import PyroClient
|
|||||||
from userbot.modules.capture import repository
|
from userbot.modules.capture import repository
|
||||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||||
from userbot.modules.capture.message import sender_id
|
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
|
||||||
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
|
|
||||||
@PyroClient.on_edited_message()
|
@PyroClient.on_edited_message()
|
||||||
@@ -18,7 +19,7 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
|||||||
toggles = ctx.resolve(meta)
|
toggles = ctx.resolve(meta)
|
||||||
if not toggles.track_edits_deletes:
|
if not toggles.track_edits_deletes:
|
||||||
return
|
return
|
||||||
await repository.add_version(
|
changed = await repository.add_version(
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
ctx.account_id,
|
ctx.account_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -28,9 +29,17 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
|||||||
message.text or message.caption,
|
message.text or message.caption,
|
||||||
str(message),
|
str(message),
|
||||||
message.edit_date,
|
message.edit_date,
|
||||||
|
media_unique_id(message),
|
||||||
has_media=message.media is not None,
|
has_media=message.media is not None,
|
||||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||||
)
|
)
|
||||||
|
if not changed:
|
||||||
|
return
|
||||||
|
if message.media is not None:
|
||||||
|
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
||||||
|
await notify_bg_event(
|
||||||
|
ctx.pool, "edit", ctx.account_id, chat_id=chat_id, message_id=message.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_edited_message.handlers
|
handlers = on_edited_message.handlers
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from userbot.modules.capture import capture_message
|
|||||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||||
from userbot.modules.stt import is_transcribable
|
from userbot.modules.stt import is_transcribable
|
||||||
from userbot.modules.stt.gate import safe_transcribe
|
from userbot.modules.stt.gate import safe_transcribe
|
||||||
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
|
|
||||||
@PyroClient.on_message()
|
@PyroClient.on_message()
|
||||||
@@ -18,6 +19,9 @@ async def on_message(client: PyroClient, message: Message) -> None:
|
|||||||
if not toggles.messages:
|
if not toggles.messages:
|
||||||
return
|
return
|
||||||
await capture_message(client, message, ctx, toggles)
|
await capture_message(client, message, ctx, toggles)
|
||||||
|
await notify_bg_event(
|
||||||
|
ctx.pool, "message", ctx.account_id, chat_id=meta.chat_id, message_id=message.id
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
toggles.stt
|
toggles.stt
|
||||||
and is_transcribable(message)
|
and is_transcribable(message)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pyrogram.types import User
|
|||||||
|
|
||||||
from userbot import PyroClient
|
from userbot import PyroClient
|
||||||
from userbot.modules.presence import repository
|
from userbot.modules.presence import repository
|
||||||
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
|
|
||||||
@PyroClient.on_user_status()
|
@PyroClient.on_user_status()
|
||||||
@@ -22,6 +23,7 @@ async def on_user_status(client: PyroClient, user: User) -> None:
|
|||||||
str(user.raw),
|
str(user.raw),
|
||||||
)
|
)
|
||||||
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online")
|
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online")
|
||||||
|
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_user_status.handlers
|
handlers = on_user_status.handlers
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pyrogram import raw, utils
|
|||||||
from userbot import PyroClient
|
from userbot import PyroClient
|
||||||
from userbot.modules.capture import repository
|
from userbot.modules.capture import repository
|
||||||
from userbot.modules.capture.chat_meta import meta_from_peer
|
from userbot.modules.capture.chat_meta import meta_from_peer
|
||||||
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
HANDLES = (raw.types.UpdateMessageReactions,)
|
HANDLES = (raw.types.UpdateMessageReactions,)
|
||||||
|
|
||||||
@@ -47,3 +48,10 @@ async def handle(
|
|||||||
await repository.sync_reactions(
|
await repository.sync_reactions(
|
||||||
ctx.pool, ctx.account_id, meta.chat_id, update.msg_id, current
|
ctx.pool, ctx.account_id, meta.chat_id, update.msg_id, current
|
||||||
)
|
)
|
||||||
|
await notify_bg_event(
|
||||||
|
ctx.pool,
|
||||||
|
"reaction",
|
||||||
|
ctx.account_id,
|
||||||
|
chat_id=meta.chat_id,
|
||||||
|
message_id=update.msg_id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pyrogram import raw, utils
|
|||||||
|
|
||||||
from userbot import PyroClient
|
from userbot import PyroClient
|
||||||
from userbot.modules.read_receipts import repository
|
from userbot.modules.read_receipts import repository
|
||||||
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
HANDLES = (raw.types.UpdateReadHistoryOutbox,)
|
HANDLES = (raw.types.UpdateReadHistoryOutbox,)
|
||||||
|
|
||||||
@@ -24,3 +25,6 @@ async def handle(
|
|||||||
update.max_id,
|
update.max_id,
|
||||||
str(update),
|
str(update),
|
||||||
)
|
)
|
||||||
|
await notify_bg_event(
|
||||||
|
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=update.max_id
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from userbot.modules.avatars.downloader import capture_avatar
|
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
|
import asyncpg
|
||||||
|
|
||||||
_INSERT_AVATAR = """
|
_INSERT_AVATAR = """
|
||||||
@@ -13,6 +15,16 @@ SELECT 1 FROM avatars
|
|||||||
WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
|
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(
|
async def avatar_exists(
|
||||||
pool: asyncpg.Pool, account_id: int, owner_id: int, unique_id: str
|
pool: asyncpg.Pool, account_id: int, owner_id: int, unique_id: str
|
||||||
@@ -46,3 +58,54 @@ async def insert_avatar( # noqa: PLR0913
|
|||||||
downloaded,
|
downloaded,
|
||||||
raw,
|
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
|
import asyncpg
|
||||||
from pyrogram import Client
|
from pyrogram import Client
|
||||||
|
|
||||||
|
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
|
||||||
from userbot.modules.contacts import ContactCache
|
from userbot.modules.contacts import ContactCache
|
||||||
from userbot.modules.folders import FolderCache
|
from userbot.modules.folders import FolderCache
|
||||||
from userbot.modules.watches import WatchCache
|
from userbot.modules.watches import WatchCache
|
||||||
@@ -25,6 +26,8 @@ class CaptureContext:
|
|||||||
self.folders = folders
|
self.folders = folders
|
||||||
self.contacts = contacts
|
self.contacts = contacts
|
||||||
self.watches = WatchCache(pool, account_id)
|
self.watches = WatchCache(pool, account_id)
|
||||||
|
self.peer_identity = PeerIdentityCache()
|
||||||
|
self.chat_meta = ChatMetaCache()
|
||||||
self.policies = PolicySet()
|
self.policies = PolicySet()
|
||||||
|
|
||||||
async def reload_policies(self) -> None:
|
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,
|
has_media=message.media is not None,
|
||||||
is_self_destruct=self_destruct_ttl(message) 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)
|
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
||||||
buttons = callbacks(message)
|
buttons = callbacks(message)
|
||||||
if buttons:
|
if buttons:
|
||||||
|
|||||||
@@ -23,28 +23,71 @@ INSERT INTO messages
|
|||||||
has_media, is_self_destruct, edited_at)
|
has_media, is_self_destruct, edited_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
|
||||||
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
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()
|
edited_at = now()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_INSERT_VERSION = """
|
_INSERT_VERSION = """
|
||||||
INSERT INTO message_versions
|
INSERT INTO message_versions
|
||||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
(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
|
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_MEDIA = """
|
||||||
INSERT INTO media
|
INSERT INTO media
|
||||||
(account_id, chat_id, message_id, kind, storage_key, file_size, mime,
|
(account_id, chat_id, message_id, kind, storage_key, file_size, mime,
|
||||||
ttl_seconds, downloaded)
|
ttl_seconds, downloaded, unique_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
|
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
|
||||||
kind = EXCLUDED.kind,
|
kind = EXCLUDED.kind,
|
||||||
storage_key = EXCLUDED.storage_key,
|
storage_key = EXCLUDED.storage_key,
|
||||||
file_size = EXCLUDED.file_size,
|
file_size = EXCLUDED.file_size,
|
||||||
mime = EXCLUDED.mime,
|
mime = EXCLUDED.mime,
|
||||||
ttl_seconds = EXCLUDED.ttl_seconds,
|
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,
|
text: str | None,
|
||||||
raw: str,
|
raw: str,
|
||||||
edit_date: datetime | None,
|
edit_date: datetime | None,
|
||||||
|
media_unique_id: str | None,
|
||||||
*,
|
*,
|
||||||
has_media: bool,
|
has_media: bool,
|
||||||
is_self_destruct: bool,
|
is_self_destruct: bool,
|
||||||
) -> None:
|
) -> bool:
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
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(
|
await conn.execute(
|
||||||
_TOUCH_EDITED,
|
_TOUCH_EDITED,
|
||||||
account_id,
|
account_id,
|
||||||
@@ -131,6 +183,13 @@ async def add_version( # noqa: PLR0913
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
_INSERT_VERSION, account_id, chat_id, message_id, edit_date, text, raw
|
_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
|
async def insert_media( # noqa: PLR0913
|
||||||
@@ -143,21 +202,37 @@ async def insert_media( # noqa: PLR0913
|
|||||||
file_size: int | None,
|
file_size: int | None,
|
||||||
mime: str | None,
|
mime: str | None,
|
||||||
ttl_seconds: int | None,
|
ttl_seconds: int | None,
|
||||||
|
unique_id: str | None,
|
||||||
*,
|
*,
|
||||||
downloaded: bool,
|
downloaded: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
await pool.execute(
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
_INSERT_MEDIA,
|
await conn.execute(
|
||||||
account_id,
|
_INSERT_MEDIA,
|
||||||
chat_id,
|
account_id,
|
||||||
message_id,
|
chat_id,
|
||||||
kind,
|
message_id,
|
||||||
storage_key,
|
kind,
|
||||||
file_size,
|
storage_key,
|
||||||
mime,
|
file_size,
|
||||||
ttl_seconds,
|
mime,
|
||||||
downloaded,
|
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(
|
async def insert_callbacks(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import asyncpg
|
||||||
|
|
||||||
|
_GET = """
|
||||||
|
SELECT downloaded FROM custom_emoji WHERE custom_emoji_id = $1
|
||||||
|
"""
|
||||||
|
|
||||||
|
_UPSERT_DOWNLOADED = """
|
||||||
|
INSERT INTO custom_emoji
|
||||||
|
(custom_emoji_id, storage_key, file_size, mime, kind, downloaded, raw)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, true, '{}'::jsonb)
|
||||||
|
ON CONFLICT (custom_emoji_id) DO UPDATE SET
|
||||||
|
storage_key = EXCLUDED.storage_key,
|
||||||
|
file_size = EXCLUDED.file_size,
|
||||||
|
mime = EXCLUDED.mime,
|
||||||
|
kind = EXCLUDED.kind,
|
||||||
|
downloaded = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def is_downloaded(pool: asyncpg.Pool, custom_emoji_id: int) -> bool:
|
||||||
|
return bool(await pool.fetchval(_GET, custom_emoji_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_downloaded( # noqa: PLR0913
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
custom_emoji_id: int,
|
||||||
|
storage_key: str,
|
||||||
|
file_size: int | None,
|
||||||
|
mime: str | None,
|
||||||
|
kind: str,
|
||||||
|
) -> None:
|
||||||
|
await pool.execute(
|
||||||
|
_UPSERT_DOWNLOADED, custom_emoji_id, storage_key, file_size, mime, kind
|
||||||
|
)
|
||||||
@@ -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
|
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
|
async def insert_chat_history( # noqa: PLR0913
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
from userbot.modules.jobs.handlers import backfill, fetch_media, transcribe
|
from userbot.modules.jobs.handlers import (
|
||||||
|
backfill,
|
||||||
|
enrich_chat,
|
||||||
|
fetch_avatar,
|
||||||
|
fetch_custom_emoji,
|
||||||
|
fetch_media,
|
||||||
|
sync_dialogs,
|
||||||
|
transcribe,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["backfill", "fetch_media", "transcribe"]
|
__all__ = [
|
||||||
|
"backfill",
|
||||||
|
"enrich_chat",
|
||||||
|
"fetch_avatar",
|
||||||
|
"fetch_custom_emoji",
|
||||||
|
"fetch_media",
|
||||||
|
"sync_dialogs",
|
||||||
|
"transcribe",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from pyrogram.errors import PeerIdInvalid
|
||||||
|
|
||||||
from userbot.modules.capture import capture_message
|
from userbot.modules.capture import capture_message
|
||||||
from userbot.modules.jobs.context import JobContext
|
from userbot.modules.jobs.context import JobContext
|
||||||
from userbot.modules.jobs.registry import register
|
from userbot.modules.jobs.registry import register
|
||||||
@@ -23,11 +25,15 @@ async def backfill(ctx: JobContext) -> None:
|
|||||||
max_id = (ctx.job.cursor or {}).get("max_id", 0)
|
max_id = (ctx.job.cursor or {}).get("max_id", 0)
|
||||||
processed = ctx.job.progress.get("processed", 0)
|
processed = ctx.job.progress.get("processed", 0)
|
||||||
kwargs = {"max_id": max_id} if max_id else {}
|
kwargs = {"max_id": max_id} if max_id else {}
|
||||||
async for message in client.get_chat_history(chat_id, **kwargs):
|
try:
|
||||||
await capture_message(client, message, capture, toggles)
|
async for message in client.get_chat_history(chat_id, **kwargs):
|
||||||
processed += 1
|
await capture_message(client, message, capture, toggles)
|
||||||
if processed % SAVE_EVERY == 0:
|
processed += 1
|
||||||
next_max = message.id - 1
|
if processed % SAVE_EVERY == 0:
|
||||||
await ctx.save_cursor({"max_id": next_max})
|
next_max = message.id - 1
|
||||||
await ctx.report_progress({"processed": processed, "max_id": next_max})
|
await ctx.save_cursor({"max_id": next_max})
|
||||||
|
await ctx.report_progress({"processed": processed, "max_id": next_max})
|
||||||
|
except PeerIdInvalid:
|
||||||
|
await ctx.report_progress({"processed": processed, "error": "peer_id_invalid"})
|
||||||
|
return
|
||||||
await ctx.report_progress({"processed": processed, "done": True})
|
await ctx.report_progress({"processed": processed, "done": True})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from pyrogram.types import Sticker
|
||||||
|
|
||||||
|
from userbot.modules.custom_emoji.repository import is_downloaded, upsert_downloaded
|
||||||
|
from userbot.modules.jobs.context import JobContext
|
||||||
|
from userbot.modules.jobs.registry import register
|
||||||
|
|
||||||
|
|
||||||
|
def _kind(sticker: Sticker) -> str:
|
||||||
|
if sticker.is_animated:
|
||||||
|
return "animated"
|
||||||
|
if sticker.is_video:
|
||||||
|
return "video"
|
||||||
|
return "static"
|
||||||
|
|
||||||
|
|
||||||
|
@register("fetch_custom_emoji")
|
||||||
|
async def fetch_custom_emoji(ctx: JobContext) -> None:
|
||||||
|
client = ctx.client
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
capture = getattr(client, "capture", None)
|
||||||
|
if capture is None:
|
||||||
|
return
|
||||||
|
custom_emoji_id = int(ctx.job.params["custom_emoji_id"])
|
||||||
|
if await is_downloaded(ctx.pool, custom_emoji_id):
|
||||||
|
return
|
||||||
|
stickers = await client.get_custom_emoji_stickers([str(custom_emoji_id)])
|
||||||
|
if not stickers:
|
||||||
|
return
|
||||||
|
sticker = stickers[0]
|
||||||
|
buffer = await client.download_media(sticker.file_id, in_memory=True)
|
||||||
|
if not isinstance(buffer, BytesIO):
|
||||||
|
return
|
||||||
|
data = buffer.getvalue()
|
||||||
|
storage_key = capture.storage.put(data)
|
||||||
|
await upsert_downloaded(
|
||||||
|
ctx.pool,
|
||||||
|
custom_emoji_id,
|
||||||
|
storage_key,
|
||||||
|
len(data),
|
||||||
|
sticker.mime_type,
|
||||||
|
_kind(sticker),
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.errors import BadRequest, Forbidden
|
||||||
|
from pyrogram.types import Chat, 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_chat, snapshot_from_high_level
|
||||||
|
from userbot.modules.profiles.repository import write_profile
|
||||||
|
|
||||||
|
SAVE_EVERY = 100
|
||||||
|
USERS_BATCH = 200
|
||||||
|
|
||||||
|
_UPSERT_DIALOG = """
|
||||||
|
INSERT INTO dialogs (account_id, chat_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (account_id, chat_id) DO UPDATE SET updated_at = now()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_private(ctx: CaptureContext, chat: Chat, chat_id: int) -> bool:
|
||||||
|
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
|
||||||
|
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, str(chat))
|
||||||
|
if photo_file_id and photo_unique_id:
|
||||||
|
await note_avatar(
|
||||||
|
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
|
||||||
|
)
|
||||||
|
return bool(fields.first_name or fields.last_name or fields.username)
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) -> None:
|
||||||
|
for start in range(0, len(ids), USERS_BATCH):
|
||||||
|
batch = ids[start : start + USERS_BATCH]
|
||||||
|
try:
|
||||||
|
result = await client.get_users(batch)
|
||||||
|
except (BadRequest, Forbidden):
|
||||||
|
continue
|
||||||
|
users = result if isinstance(result, list) else [result]
|
||||||
|
for user in users:
|
||||||
|
if not isinstance(user, User):
|
||||||
|
continue
|
||||||
|
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 _save_group(ctx: CaptureContext, chat: Chat, chat_id: int) -> None:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register("sync_dialogs")
|
||||||
|
async def sync_dialogs(ctx: JobContext) -> None:
|
||||||
|
client = ctx.client
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
capture = getattr(client, "capture", None)
|
||||||
|
if capture is None:
|
||||||
|
return
|
||||||
|
processed = ctx.job.progress.get("processed", 0)
|
||||||
|
nameless: list[int] = []
|
||||||
|
async for dialog in client.get_dialogs():
|
||||||
|
chat = dialog.chat
|
||||||
|
if chat is None or chat.id is None:
|
||||||
|
continue
|
||||||
|
chat_id = chat.id
|
||||||
|
try:
|
||||||
|
if chat_id > 0:
|
||||||
|
if not await _save_private(capture, chat, chat_id):
|
||||||
|
nameless.append(chat_id)
|
||||||
|
else:
|
||||||
|
await _save_group(capture, chat, chat_id)
|
||||||
|
except (BadRequest, Forbidden):
|
||||||
|
pass
|
||||||
|
await ctx.pool.execute(_UPSERT_DIALOG, ctx.account_id, chat_id)
|
||||||
|
processed += 1
|
||||||
|
if processed % SAVE_EVERY == 0:
|
||||||
|
await ctx.report_progress({"processed": processed})
|
||||||
|
await _enrich_users(client, capture, nameless)
|
||||||
|
await ctx.report_progress({"processed": processed, "done": True})
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -20,11 +20,20 @@ _MEDIA_ATTRS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_WEB_PAGE_ATTRS = ("photo", "video", "animation", "document", "audio")
|
||||||
|
|
||||||
|
|
||||||
def media_object(message: Message) -> tuple[str | None, Any]:
|
def media_object(message: Message) -> tuple[str | None, Any]:
|
||||||
for attr in _MEDIA_ATTRS:
|
for attr in _MEDIA_ATTRS:
|
||||||
obj = getattr(message, attr, None)
|
obj = getattr(message, attr, None)
|
||||||
if obj is not None:
|
if obj is not None:
|
||||||
return attr, obj
|
return attr, obj
|
||||||
|
web_page = getattr(message, "web_page", None)
|
||||||
|
if web_page is not None:
|
||||||
|
for attr in _WEB_PAGE_ATTRS:
|
||||||
|
obj = getattr(web_page, attr, None)
|
||||||
|
if obj is not None:
|
||||||
|
return attr, obj
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +42,11 @@ def self_destruct_ttl(message: Message) -> int | None:
|
|||||||
return getattr(obj, "ttl_seconds", None) if obj is not None else 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
|
async def capture_media( # noqa: PLR0913
|
||||||
client: Client,
|
client: Client,
|
||||||
message: Message,
|
message: Message,
|
||||||
@@ -44,6 +58,7 @@ async def capture_media( # noqa: PLR0913
|
|||||||
kind, obj = media_object(message)
|
kind, obj = media_object(message)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return
|
return
|
||||||
|
unique_id = getattr(obj, "file_unique_id", None)
|
||||||
ttl = getattr(obj, "ttl_seconds", None)
|
ttl = getattr(obj, "ttl_seconds", None)
|
||||||
want = toggles.self_destruct_media if ttl else toggles.media
|
want = toggles.self_destruct_media if ttl else toggles.media
|
||||||
file_size = getattr(obj, "file_size", None)
|
file_size = getattr(obj, "file_size", None)
|
||||||
@@ -51,12 +66,26 @@ async def capture_media( # noqa: PLR0913
|
|||||||
storage_key: str | None = None
|
storage_key: str | None = None
|
||||||
downloaded = False
|
downloaded = False
|
||||||
if want:
|
if want:
|
||||||
buffer = await client.download_media(message, in_memory=True)
|
existing = await repository.current_media(
|
||||||
if isinstance(buffer, BytesIO):
|
ctx.pool, ctx.account_id, chat_id, message_id
|
||||||
data = buffer.getvalue()
|
)
|
||||||
storage_key = ctx.storage.put(data)
|
if (
|
||||||
file_size = len(data)
|
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
|
downloaded = True
|
||||||
|
else:
|
||||||
|
target = message if getattr(message, kind or "", None) is obj else obj
|
||||||
|
buffer = await client.download_media(target, 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(
|
await repository.insert_media(
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
ctx.account_id,
|
ctx.account_id,
|
||||||
@@ -67,5 +96,6 @@ async def capture_media( # noqa: PLR0913
|
|||||||
file_size,
|
file_size,
|
||||||
mime,
|
mime,
|
||||||
ttl,
|
ttl,
|
||||||
|
unique_id,
|
||||||
downloaded=downloaded,
|
downloaded=downloaded,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
from userbot.modules.profiles.parse import (
|
from userbot.modules.profiles.parse import (
|
||||||
ProfileFields,
|
ProfileFields,
|
||||||
active_username,
|
active_username,
|
||||||
|
snapshot_from_high_level,
|
||||||
snapshot_from_user,
|
snapshot_from_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["ProfileFields", "active_username", "snapshot_from_user"]
|
__all__ = [
|
||||||
|
"ProfileFields",
|
||||||
|
"active_username",
|
||||||
|
"snapshot_from_high_level",
|
||||||
|
"snapshot_from_user",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from pyrogram import Client, raw
|
from pyrogram import Client, raw
|
||||||
from pyrogram.types import User
|
from pyrogram.types import Chat, User
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -37,3 +37,35 @@ def snapshot_from_user(
|
|||||||
is_deleted_account=bool(getattr(raw_user, "deleted", False)),
|
is_deleted_account=bool(getattr(raw_user, "deleted", False)),
|
||||||
)
|
)
|
||||||
return fields, photo_file_id, photo_unique_id
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_from_chat(chat: Chat) -> tuple[ProfileFields, str | None, str | None]:
|
||||||
|
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
|
||||||
|
fields = ProfileFields(
|
||||||
|
first_name=chat.first_name,
|
||||||
|
last_name=chat.last_name,
|
||||||
|
username=chat.username,
|
||||||
|
phone=None,
|
||||||
|
photo_unique_id=photo_unique_id,
|
||||||
|
is_deleted_account=False,
|
||||||
|
)
|
||||||
|
return fields, photo_file_id, photo_unique_id
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from userbot import PyroClient
|
|||||||
from userbot.modules.capture import CaptureContext, build_capture_context
|
from userbot.modules.capture import CaptureContext, build_capture_context
|
||||||
from userbot.modules.jobs import JobConsumer
|
from userbot.modules.jobs import JobConsumer
|
||||||
from utils.env import env
|
from utils.env import env
|
||||||
|
from utils.jobs import enqueue
|
||||||
from utils.logging import logger, setup_logging
|
from utils.logging import logger, setup_logging
|
||||||
from utils.read.watches import WATCHES_CHANGED_CHANNEL
|
from utils.read.watches import WATCHES_CHANGED_CHANNEL
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -72,6 +73,17 @@ async def _setup_capture(
|
|||||||
logger.info("[green]Capture context ready.[/]")
|
logger.info("[green]Capture context ready.[/]")
|
||||||
|
|
||||||
|
|
||||||
|
async def _enqueue_sync_dialogs(pool: asyncpg.Pool, account_id: int) -> None:
|
||||||
|
existing = await pool.fetchval(
|
||||||
|
"SELECT 1 FROM jobs WHERE account_id = $1 AND kind = 'sync_dialogs' "
|
||||||
|
"AND status IN ('pending', 'running') LIMIT 1",
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
await enqueue(pool, account_id, "sync_dialogs", {})
|
||||||
|
logger.info("[green]Queued sync_dialogs.[/]")
|
||||||
|
|
||||||
|
|
||||||
async def _listen_changes(
|
async def _listen_changes(
|
||||||
clients: list[PyroClient], tasks: set[asyncio.Task]
|
clients: list[PyroClient], tasks: set[asyncio.Task]
|
||||||
) -> asyncpg.Connection:
|
) -> asyncpg.Connection:
|
||||||
@@ -134,6 +146,7 @@ async def runner() -> None:
|
|||||||
await _setup_capture(pool, client, account_id, storage)
|
await _setup_capture(pool, client, account_id, storage)
|
||||||
consumer = JobConsumer(client, pool, account_id)
|
consumer = JobConsumer(client, pool, account_id)
|
||||||
consumer_tasks.append(asyncio.create_task(consumer.run()))
|
consumer_tasks.append(asyncio.create_task(consumer.run()))
|
||||||
|
await _enqueue_sync_dialogs(pool, account_id)
|
||||||
|
|
||||||
if clients:
|
if clients:
|
||||||
listen_conn = await _listen_changes(clients, reload_tasks)
|
listen_conn = await _listen_changes(clients, reload_tasks)
|
||||||
|
|||||||
@@ -477,3 +477,18 @@ class Alert(SQLModel, table=True):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Dialog(SQLModel, table=True):
|
||||||
|
__tablename__ = "dialogs"
|
||||||
|
|
||||||
|
account_id: int = Field(primary_key=True)
|
||||||
|
chat_id: int = Field(primary_key=True)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class TelegramSettings(BaseSettings):
|
|||||||
class ApiSettings(BaseSettings):
|
class ApiSettings(BaseSettings):
|
||||||
host: str = "0.0.0.0" # noqa: S104
|
host: str = "0.0.0.0" # noqa: S104
|
||||||
port: int = 8080
|
port: int = 8080
|
||||||
|
static_dir: str = "static"
|
||||||
|
|
||||||
|
|
||||||
class AuthSettings(BaseSettings):
|
class AuthSettings(BaseSettings):
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
BG_EVENTS_CHANNEL = "bg_events"
|
||||||
|
|
||||||
|
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_bg_event( # noqa: PLR0913
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
kind: EventKind,
|
||||||
|
account_id: int,
|
||||||
|
*,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
message_id: int | None = None,
|
||||||
|
message_ids: list[int] | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload: dict[str, object] = {"kind": kind, "account_id": account_id}
|
||||||
|
if chat_id is not None:
|
||||||
|
payload["chat_id"] = chat_id
|
||||||
|
if message_id is not None:
|
||||||
|
payload["message_id"] = message_id
|
||||||
|
if message_ids is not None:
|
||||||
|
payload["message_ids"] = message_ids
|
||||||
|
await pool.execute(
|
||||||
|
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.models import AccountView
|
||||||
|
|
||||||
|
|
||||||
|
async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
|
||||||
|
return await pool.fetchval(
|
||||||
|
"SELECT tg_user_id FROM accounts WHERE account_id = $1", account_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,69 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.accounts import self_user_id
|
||||||
|
from utils.read.models import ResponseStats, VolumeBucket
|
||||||
|
|
||||||
|
|
||||||
|
async def message_volume(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 90
|
||||||
|
) -> list[VolumeBucket]:
|
||||||
|
self_id = await self_user_id(pool, account_id)
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT date_trunc('day', date) AS bucket, count(*) AS total, "
|
||||||
|
"count(*) FILTER (WHERE sender_id = $3) AS outgoing "
|
||||||
|
"FROM messages "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND date >= $4 "
|
||||||
|
"GROUP BY bucket ORDER BY bucket",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
self_id,
|
||||||
|
datetime.now(UTC) - timedelta(days=days),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
VolumeBucket(
|
||||||
|
bucket=row["bucket"],
|
||||||
|
total=row["total"],
|
||||||
|
outgoing=row["outgoing"],
|
||||||
|
incoming=row["total"] - row["outgoing"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def response_stats(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||||
|
) -> ResponseStats:
|
||||||
|
self_id = await self_user_id(pool, account_id)
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"WITH ordered AS ("
|
||||||
|
"SELECT sender_id, date, "
|
||||||
|
"lag(sender_id) OVER w AS prev_sender, "
|
||||||
|
"lag(date) OVER w AS prev_date "
|
||||||
|
"FROM messages "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND sender_id IS NOT NULL "
|
||||||
|
"WINDOW w AS (ORDER BY date, message_id)), "
|
||||||
|
"resp AS ("
|
||||||
|
"SELECT (sender_id = $3) AS is_mine, "
|
||||||
|
"EXTRACT(EPOCH FROM (date - prev_date)) AS secs "
|
||||||
|
"FROM ordered "
|
||||||
|
"WHERE prev_sender IS NOT NULL AND prev_sender <> sender_id) "
|
||||||
|
"SELECT is_mine, count(*) AS n, "
|
||||||
|
"percentile_cont(0.5) WITHIN GROUP (ORDER BY secs) AS median_secs "
|
||||||
|
"FROM resp GROUP BY is_mine",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
self_id,
|
||||||
|
)
|
||||||
|
stats = ResponseStats(
|
||||||
|
mine_median_seconds=None, mine_count=0, their_median_seconds=None, their_count=0
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
if row["is_mine"]:
|
||||||
|
stats.mine_median_seconds = row["median_secs"]
|
||||||
|
stats.mine_count = row["n"]
|
||||||
|
else:
|
||||||
|
stats.their_median_seconds = row["median_secs"]
|
||||||
|
stats.their_count = row["n"]
|
||||||
|
return stats
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.models import AvatarHistoryView, 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
|
||||||
|
"""
|
||||||
|
|
||||||
|
_AVATAR_HISTORY = """
|
||||||
|
SELECT unique_id, first_seen_at, downloaded FROM avatars
|
||||||
|
WHERE account_id = $1 AND owner_id = $2
|
||||||
|
ORDER BY first_seen_at DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def avatar_by_unique_id(
|
||||||
|
pool: asyncpg.Pool, account_id: int, owner_id: int, unique_id: str
|
||||||
|
) -> AvatarRef | None:
|
||||||
|
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
|
||||||
|
return AvatarRef(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def avatar_history(
|
||||||
|
pool: asyncpg.Pool, account_id: int, owner_id: int
|
||||||
|
) -> list[AvatarHistoryView]:
|
||||||
|
rows = await pool.fetch(_AVATAR_HISTORY, account_id, owner_id)
|
||||||
|
return [AvatarHistoryView(**dict(row)) for row in rows]
|
||||||
+176
-18
@@ -1,13 +1,48 @@
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.read.models import ChatListItem, MessageVersionView, MessageView, Page
|
from utils.read.accounts import self_user_id
|
||||||
|
from utils.read.message_view import build_message_view, load_raw, media_ref_from
|
||||||
|
from utils.read.models import (
|
||||||
|
ChatListItem,
|
||||||
|
MediaRef,
|
||||||
|
MessageVersionView,
|
||||||
|
MessageView,
|
||||||
|
Page,
|
||||||
|
)
|
||||||
|
from utils.read.read_receipts import read_up_to
|
||||||
|
|
||||||
_MESSAGE_COLS = (
|
_MESSAGE_COLS = (
|
||||||
"chat_id, message_id, date, sender_id, text, "
|
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
|
||||||
"has_media, is_self_destruct, edited_at, deleted_at"
|
"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(
|
def _peer_title(
|
||||||
first: str | None, last: str | None, username: str | None
|
first: str | None, last: str | None, username: str | None
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -19,18 +54,43 @@ async def list_chats(
|
|||||||
pool: asyncpg.Pool, account_id: int, page: Page
|
pool: asyncpg.Pool, account_id: int, page: Page
|
||||||
) -> list[ChatListItem]:
|
) -> list[ChatListItem]:
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"SELECT m.chat_id, count(*) AS message_count, max(m.date) AS last_date, "
|
"WITH ids AS ("
|
||||||
|
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 "
|
||||||
|
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1), "
|
||||||
|
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date "
|
||||||
|
"FROM messages WHERE account_id = $1 GROUP BY chat_id) "
|
||||||
|
"SELECT ids.chat_id, COALESCE(agg.message_count, 0) AS message_count, "
|
||||||
|
"agg.last_date AS last_date, "
|
||||||
"(SELECT p.first_name FROM peers p "
|
"(SELECT p.first_name FROM peers p "
|
||||||
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS first_name, "
|
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS first_name, "
|
||||||
"(SELECT p.last_name FROM peers p "
|
"(SELECT p.last_name FROM peers p "
|
||||||
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS last_name, "
|
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS last_name, "
|
||||||
"(SELECT p.username FROM peers p "
|
"(SELECT p.username FROM peers p "
|
||||||
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS username, "
|
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS username, "
|
||||||
"(SELECT ch.title FROM chat_history ch "
|
"(SELECT ch.title FROM chat_history ch "
|
||||||
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
|
"WHERE ch.account_id = $1 AND ch.chat_id = ids.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, "
|
||||||
"FROM messages m WHERE m.account_id = $1 "
|
"EXISTS (SELECT 1 FROM avatars a "
|
||||||
"GROUP BY m.chat_id ORDER BY last_date DESC LIMIT $2 OFFSET $3",
|
"WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar, "
|
||||||
|
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, "
|
||||||
|
"p.raw->>'type' = 'ChatType.BOT', false) "
|
||||||
|
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = ids.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 = ids.chat_id) AS is_contact, "
|
||||||
|
"(SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') "
|
||||||
|
"= 'ChatType.CHANNEL' FROM chat_history ch "
|
||||||
|
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
|
||||||
|
"AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'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 = ids.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 = ids.chat_id "
|
||||||
|
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_sender_id "
|
||||||
|
"FROM ids LEFT JOIN agg ON agg.chat_id = ids.chat_id "
|
||||||
|
"ORDER BY last_date DESC NULLS LAST, ids.chat_id DESC LIMIT $2 OFFSET $3",
|
||||||
account_id,
|
account_id,
|
||||||
page.capped_limit,
|
page.capped_limit,
|
||||||
page.offset,
|
page.offset,
|
||||||
@@ -44,33 +104,124 @@ async def list_chats(
|
|||||||
ChatListItem(
|
ChatListItem(
|
||||||
chat_id=row["chat_id"],
|
chat_id=row["chat_id"],
|
||||||
title=title,
|
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"],
|
message_count=row["message_count"],
|
||||||
last_date=row["last_date"],
|
last_date=row["last_date"],
|
||||||
|
last_text=row["last_text"],
|
||||||
|
last_sender_id=row["last_sender_id"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
async def get_chat_history(
|
async def get_chat_history( # noqa: PLR0913
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
page: Page,
|
page: Page,
|
||||||
*,
|
*,
|
||||||
include_deleted: bool = True,
|
include_deleted: bool = True,
|
||||||
|
before_id: int | None = None,
|
||||||
|
after_id: int | None = None,
|
||||||
) -> list[MessageView]:
|
) -> list[MessageView]:
|
||||||
where = "account_id = $1 AND chat_id = $2"
|
where = "account_id = $1 AND chat_id = $2"
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
where += " AND deleted_at IS NULL"
|
where += " AND deleted_at IS NULL"
|
||||||
rows = await pool.fetch(
|
params: list[object] = [account_id, chat_id]
|
||||||
|
if after_id is not None:
|
||||||
|
params.append(after_id)
|
||||||
|
where += f" AND message_id > ${len(params)}"
|
||||||
|
order = "date ASC, message_id ASC"
|
||||||
|
elif before_id is not None:
|
||||||
|
params.append(before_id)
|
||||||
|
where += f" AND message_id < ${len(params)}"
|
||||||
|
order = "date DESC, message_id DESC"
|
||||||
|
else:
|
||||||
|
order = "date DESC, message_id DESC"
|
||||||
|
params.append(page.capped_limit)
|
||||||
|
query = (
|
||||||
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
|
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
|
||||||
"ORDER BY date DESC, message_id DESC LIMIT $3 OFFSET $4",
|
f"ORDER BY {order} LIMIT ${len(params)}"
|
||||||
|
)
|
||||||
|
if before_id is None and after_id is None:
|
||||||
|
params.append(page.offset)
|
||||||
|
query += f" OFFSET ${len(params)}"
|
||||||
|
rows = await pool.fetch(query, *params)
|
||||||
|
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
|
||||||
|
await _apply_read_status(pool, account_id, chat_id, views)
|
||||||
|
return views
|
||||||
|
|
||||||
|
|
||||||
|
async def get_message(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||||
|
) -> MessageView | None:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"SELECT {_MESSAGE_COLS} FROM messages " # noqa: S608
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
|
||||||
account_id,
|
account_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
page.capped_limit,
|
message_id,
|
||||||
page.offset,
|
|
||||||
)
|
)
|
||||||
return [MessageView(**dict(row)) for row in rows]
|
if row is None:
|
||||||
|
return None
|
||||||
|
media_by_key = await _media_map(pool, account_id, [row])
|
||||||
|
raw = load_raw(row["raw"])
|
||||||
|
view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
|
||||||
|
await _apply_read_status(pool, account_id, chat_id, [view])
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_read_status(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
|
||||||
|
) -> None:
|
||||||
|
self_id = await self_user_id(pool, account_id)
|
||||||
|
if self_id is None:
|
||||||
|
return
|
||||||
|
marker = await read_up_to(pool, account_id, chat_id)
|
||||||
|
if marker is None:
|
||||||
|
return
|
||||||
|
for view in views:
|
||||||
|
if view.sender_id == self_id and view.message_id <= marker:
|
||||||
|
view.read = True
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
async def get_deleted_messages(
|
||||||
@@ -88,7 +239,14 @@ async def get_deleted_messages(
|
|||||||
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
|
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
|
||||||
*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(
|
async def get_message_versions(
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.models import CustomEmojiRef
|
||||||
|
|
||||||
|
_GET = """
|
||||||
|
SELECT storage_key, downloaded, mime, kind FROM custom_emoji
|
||||||
|
WHERE custom_emoji_id = $1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def current_custom_emoji(
|
||||||
|
pool: asyncpg.Pool, custom_emoji_id: int
|
||||||
|
) -> CustomEmojiRef | None:
|
||||||
|
row = await pool.fetchrow(_GET, custom_emoji_id)
|
||||||
|
return CustomEmojiRef(**dict(row)) if row else None
|
||||||
@@ -1,12 +1,54 @@
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.read.models import MediaView
|
from utils.read.message_view import load_raw
|
||||||
|
from utils.read.models import MediaVersionView, MediaView
|
||||||
|
|
||||||
_MEDIA_COLS = (
|
_MEDIA_COLS = (
|
||||||
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
|
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
|
||||||
"mime, ttl_seconds, downloaded, extracted_text, created_at"
|
"mime, ttl_seconds, downloaded, extracted_text, created_at"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
|
||||||
|
|
||||||
|
_WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio")
|
||||||
|
|
||||||
|
|
||||||
|
async def _web_page_media_stub(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||||
|
) -> MediaView | None:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT date, raw FROM messages "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
web_page = load_raw(row["raw"]).get("web_page")
|
||||||
|
if not isinstance(web_page, dict):
|
||||||
|
return None
|
||||||
|
kind = next(
|
||||||
|
(k for k in _WEB_PAGE_MEDIA_KINDS if isinstance(web_page.get(k), dict)), None
|
||||||
|
)
|
||||||
|
if kind is None:
|
||||||
|
return None
|
||||||
|
obj = web_page[kind]
|
||||||
|
return MediaView(
|
||||||
|
id=0,
|
||||||
|
account_id=account_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
kind=kind,
|
||||||
|
storage_key=None,
|
||||||
|
file_size=obj.get("file_size"),
|
||||||
|
mime=obj.get("mime_type"),
|
||||||
|
ttl_seconds=None,
|
||||||
|
downloaded=False,
|
||||||
|
extracted_text=None,
|
||||||
|
created_at=row["date"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
|
async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
@@ -26,4 +68,30 @@ async def get_message_media(
|
|||||||
chat_id,
|
chat_id,
|
||||||
message_id,
|
message_id,
|
||||||
)
|
)
|
||||||
return MediaView(**dict(row)) if row else None
|
if row is not None:
|
||||||
|
return MediaView(**dict(row))
|
||||||
|
return await _web_page_media_stub(pool, account_id, chat_id, message_id)
|
||||||
|
|
||||||
|
|
||||||
|
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,146 @@ class Page(BaseModel):
|
|||||||
return min(self.limit, MAX_LIMIT)
|
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):
|
class ChatListItem(BaseModel):
|
||||||
chat_id: int
|
chat_id: int
|
||||||
title: str | None
|
title: str | None
|
||||||
|
kind: str
|
||||||
|
has_avatar: bool
|
||||||
|
is_bot: bool
|
||||||
|
is_contact: bool
|
||||||
|
is_broadcast: bool
|
||||||
message_count: int
|
message_count: int
|
||||||
last_date: datetime | None
|
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 PinnedView(BaseModel):
|
||||||
|
message_id: int
|
||||||
|
text: str | None = None
|
||||||
|
media_kind: str | None = None
|
||||||
|
sender_name: str | 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):
|
class MessageView(BaseModel):
|
||||||
@@ -33,6 +168,24 @@ class MessageView(BaseModel):
|
|||||||
is_self_destruct: bool
|
is_self_destruct: bool
|
||||||
edited_at: datetime | None
|
edited_at: datetime | None
|
||||||
deleted_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
|
||||||
|
read: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MessageVersionView(BaseModel):
|
class MessageVersionView(BaseModel):
|
||||||
@@ -56,6 +209,35 @@ class MediaView(BaseModel):
|
|||||||
created_at: datetime
|
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 AvatarHistoryView(BaseModel):
|
||||||
|
unique_id: str
|
||||||
|
first_seen_at: datetime
|
||||||
|
downloaded: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CustomEmojiRef(BaseModel):
|
||||||
|
storage_key: str | None
|
||||||
|
downloaded: bool
|
||||||
|
mime: str | None
|
||||||
|
kind: str | None
|
||||||
|
|
||||||
|
|
||||||
class CallbackView(BaseModel):
|
class CallbackView(BaseModel):
|
||||||
position: int
|
position: int
|
||||||
label: str | None
|
label: str | None
|
||||||
@@ -95,6 +277,20 @@ class PresenceHourly(BaseModel):
|
|||||||
last_seen: datetime | None
|
last_seen: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeBucket(BaseModel):
|
||||||
|
bucket: datetime
|
||||||
|
total: int
|
||||||
|
outgoing: int
|
||||||
|
incoming: int
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseStats(BaseModel):
|
||||||
|
mine_median_seconds: float | None
|
||||||
|
mine_count: int
|
||||||
|
their_median_seconds: float | None
|
||||||
|
their_count: int
|
||||||
|
|
||||||
|
|
||||||
class PeerView(BaseModel):
|
class PeerView(BaseModel):
|
||||||
peer_id: int
|
peer_id: int
|
||||||
first_name: str | None
|
first_name: str | None
|
||||||
@@ -103,6 +299,7 @@ class PeerView(BaseModel):
|
|||||||
phone: str | None
|
phone: str | None
|
||||||
photo_unique_id: str | None
|
photo_unique_id: str | None
|
||||||
is_deleted_account: bool
|
is_deleted_account: bool
|
||||||
|
has_avatar: bool
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +313,27 @@ class PeerHistoryView(BaseModel):
|
|||||||
is_deleted_account: bool
|
is_deleted_account: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ChatLinkView(BaseModel):
|
||||||
|
message_id: int
|
||||||
|
date: datetime | None
|
||||||
|
url: str
|
||||||
|
kind: str
|
||||||
|
web_url: str | None
|
||||||
|
web_title: str | None
|
||||||
|
web_site_name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class DayCount(BaseModel):
|
||||||
|
day: datetime
|
||||||
|
count: int
|
||||||
|
outgoing: int
|
||||||
|
|
||||||
|
|
||||||
|
class MessageAt(BaseModel):
|
||||||
|
message_id: int
|
||||||
|
date: datetime
|
||||||
|
|
||||||
|
|
||||||
class StoryView(BaseModel):
|
class StoryView(BaseModel):
|
||||||
peer_id: int
|
peer_id: int
|
||||||
story_id: int
|
story_id: int
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import asyncpg
|
|||||||
|
|
||||||
from utils.read.models import Page, PeerHistoryView, PeerView, StoryView
|
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(
|
async def get_peer(
|
||||||
pool: asyncpg.Pool, account_id: int, peer_id: int
|
pool: asyncpg.Pool, account_id: int, peer_id: int
|
||||||
) -> PeerView | None:
|
) -> PeerView | None:
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
"SELECT peer_id, first_name, last_name, username, phone, "
|
f"SELECT {_PEER_COLS} FROM peers " # noqa: S608
|
||||||
"photo_unique_id, is_deleted_account, updated_at FROM peers "
|
|
||||||
"WHERE account_id = $1 AND peer_id = $2",
|
"WHERE account_id = $1 AND peer_id = $2",
|
||||||
account_id,
|
account_id,
|
||||||
peer_id,
|
peer_id,
|
||||||
@@ -16,6 +22,20 @@ async def get_peer(
|
|||||||
return PeerView(**dict(row)) if row else None
|
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(
|
async def get_peer_history(
|
||||||
pool: asyncpg.Pool, account_id: int, peer_id: int
|
pool: asyncpg.Pool, account_id: int, peer_id: int
|
||||||
) -> list[PeerHistoryView]:
|
) -> list[PeerHistoryView]:
|
||||||
@@ -48,3 +68,17 @@ async def get_stories(
|
|||||||
*params,
|
*params,
|
||||||
)
|
)
|
||||||
return [StoryView(**dict(row)) for row in rows]
|
return [StoryView(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_story(
|
||||||
|
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
|
||||||
|
) -> StoryView | None:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, "
|
||||||
|
"storage_key, downloaded, views, pinned, deleted FROM stories "
|
||||||
|
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
|
||||||
|
account_id,
|
||||||
|
peer_id,
|
||||||
|
story_id,
|
||||||
|
)
|
||||||
|
return StoryView(**dict(row)) if row else None
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.message_view import _media_kind, _peer_name, load_raw
|
||||||
|
from utils.read.models import PinnedView
|
||||||
|
|
||||||
|
_PINNED_SQL = (
|
||||||
|
"SELECT raw FROM messages "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 "
|
||||||
|
"AND raw->>'service' LIKE '%PINNED_MESSAGE%' "
|
||||||
|
"ORDER BY date DESC, message_id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pinned(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||||
|
) -> PinnedView | None:
|
||||||
|
row = await pool.fetchrow(_PINNED_SQL, account_id, chat_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
pinned = load_raw(row["raw"]).get("pinned_message")
|
||||||
|
if not isinstance(pinned, dict):
|
||||||
|
return None
|
||||||
|
message_id = pinned.get("id")
|
||||||
|
if message_id is None:
|
||||||
|
return None
|
||||||
|
sender = pinned.get("from_user")
|
||||||
|
return PinnedView(
|
||||||
|
message_id=message_id,
|
||||||
|
text=pinned.get("text") or pinned.get("caption"),
|
||||||
|
media_kind=_media_kind(pinned),
|
||||||
|
sender_name=_peer_name(sender) if isinstance(sender, dict) else None,
|
||||||
|
)
|
||||||
@@ -33,6 +33,19 @@ async def presence_history( # noqa: PLR0913
|
|||||||
return [PresenceSample(**dict(row)) for row in rows]
|
return [PresenceSample(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def current_presence(
|
||||||
|
pool: asyncpg.Pool, account_id: int, peer_id: int
|
||||||
|
) -> PresenceSample | None:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT peer_id, ts, status, last_online_date, next_offline_date "
|
||||||
|
"FROM presence WHERE account_id = $1 AND peer_id = $2 "
|
||||||
|
"ORDER BY ts DESC LIMIT 1",
|
||||||
|
account_id,
|
||||||
|
peer_id,
|
||||||
|
)
|
||||||
|
return PresenceSample(**dict(row)) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
async def presence_hourly(
|
async def presence_hourly(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.accounts import self_user_id
|
||||||
|
from utils.read.models import ChatLinkView, DayCount, MediaView, MessageAt, Page
|
||||||
|
|
||||||
|
_MEDIA_COLS = (
|
||||||
|
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
|
||||||
|
"mime, ttl_seconds, downloaded, extracted_text, created_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_media(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, kinds: list[str], page: Page
|
||||||
|
) -> list[MediaView]:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND kind = ANY($3) "
|
||||||
|
"ORDER BY message_id DESC LIMIT $4 OFFSET $5",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
kinds,
|
||||||
|
page.capped_limit,
|
||||||
|
page.offset,
|
||||||
|
)
|
||||||
|
return [MediaView(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_links(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, page: Page
|
||||||
|
) -> list[ChatLinkView]:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT l.message_id, m.date, l.url, l.kind, l.web_url, "
|
||||||
|
"l.web_title, l.web_site_name FROM links l "
|
||||||
|
"LEFT JOIN messages m ON m.account_id = l.account_id "
|
||||||
|
"AND m.chat_id = l.chat_id AND m.message_id = l.message_id "
|
||||||
|
"WHERE l.account_id = $1 AND l.chat_id = $2 "
|
||||||
|
"ORDER BY l.message_id DESC, l.position LIMIT $3 OFFSET $4",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
page.capped_limit,
|
||||||
|
page.offset,
|
||||||
|
)
|
||||||
|
return [ChatLinkView(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def daily_counts(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||||
|
) -> list[DayCount]:
|
||||||
|
self_id = await self_user_id(pool, account_id)
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT date_trunc('day', date) AS day, count(*) AS count, "
|
||||||
|
"count(*) FILTER (WHERE sender_id = $3) AS outgoing FROM messages "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 "
|
||||||
|
"GROUP BY day ORDER BY day",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
self_id,
|
||||||
|
)
|
||||||
|
return [DayCount(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def first_message_on_day(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, day: datetime
|
||||||
|
) -> MessageAt | None:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT message_id, date FROM messages "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND date >= $3 AND date < $4 "
|
||||||
|
"ORDER BY date, message_id LIMIT 1",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
day,
|
||||||
|
day + timedelta(days=1),
|
||||||
|
)
|
||||||
|
return MessageAt(**dict(row)) if row else None
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import asyncpg
|
||||||
|
|
||||||
|
|
||||||
|
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
|
||||||
|
return await pool.fetchval(
|
||||||
|
"SELECT max(message_id) FROM read_receipts "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND kind = 'read'",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
@@ -6,3 +6,7 @@ services:
|
|||||||
api:
|
api:
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
|
|
||||||
|
frontend-dev:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5173:5173"
|
||||||
|
|||||||
@@ -72,5 +72,19 @@ services:
|
|||||||
entrypoint: [alembic]
|
entrypoint: [alembic]
|
||||||
command: [upgrade, head]
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
frontend_node_modules:
|
||||||
|
frontend_svelte_kit:
|
||||||
|
|||||||
@@ -0,0 +1,689 @@
|
|||||||
|
Beavergram frontend
|
||||||
|
Copyright (C) 2026 Beavergram contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
Portions of the UI (styles, icon font, and component structure) are
|
||||||
|
ported from Telegram A (telegram-tt), Copyright (C) Telegram-tt
|
||||||
|
contributors, also licensed under GPL-3.0:
|
||||||
|
https://github.com/Ajaxy/telegram-tt
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noUndeclaredVariables": "off",
|
"noUndeclaredVariables": "off",
|
||||||
|
"noUnusedFunctionParameters": "off",
|
||||||
"noUnusedVariables": "off"
|
"noUnusedVariables": "off"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
@@ -52,6 +53,17 @@
|
|||||||
"includes": ["src/app.html"],
|
"includes": ["src/app.html"],
|
||||||
"linter": { "enabled": false },
|
"linter": { "enabled": false },
|
||||||
"formatter": { "enabled": false }
|
"formatter": { "enabled": false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["src/lib/styles/**"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
"noDuplicateProperties": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": { "enabled": false }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
|
"dependencies": {
|
||||||
|
"bits-ui": "^2.18.1",
|
||||||
|
"lottie-web": "^5.13.0",
|
||||||
|
"pako": "^2.1.0",
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
@@ -11,6 +17,9 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/pako": "^2.0.4",
|
||||||
|
"sass": "^1.100.0",
|
||||||
"svelte": "^5.55.2",
|
"svelte": "^5.55.2",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
@@ -49,6 +58,14 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
"@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/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=="],
|
"@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 +80,34 @@
|
|||||||
|
|
||||||
"@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="],
|
"@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=="],
|
"@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=="],
|
"@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 +154,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=="],
|
"@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/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=="],
|
"@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 +194,10 @@
|
|||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
"@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/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@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=="],
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
@@ -157,9 +208,11 @@
|
|||||||
|
|
||||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"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=="],
|
"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=="],
|
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||||
|
|
||||||
@@ -175,6 +228,8 @@
|
|||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
"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=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||||
@@ -199,6 +254,14 @@
|
|||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"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=="],
|
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
@@ -235,8 +298,12 @@
|
|||||||
|
|
||||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
"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=="],
|
"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=="],
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
@@ -249,10 +316,14 @@
|
|||||||
|
|
||||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
"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=="],
|
"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=="],
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
|
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||||
@@ -267,12 +338,16 @@
|
|||||||
|
|
||||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
@@ -285,10 +360,16 @@
|
|||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"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": ["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-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=="],
|
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
@@ -305,6 +386,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=="],
|
"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=="],
|
"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=="],
|
"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 +413,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/@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=="],
|
"@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,9 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/pako": "^2.0.4",
|
||||||
|
"sass": "^1.100.0",
|
||||||
"svelte": "^5.55.2",
|
"svelte": "^5.55.2",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
@@ -27,5 +30,10 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"bits-ui": "^2.18.1",
|
||||||
|
"lottie-web": "^5.13.0",
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<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,127 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVariant(
|
||||||
|
account: number,
|
||||||
|
kind: AvatarKind,
|
||||||
|
id: number,
|
||||||
|
uniqueId: string,
|
||||||
|
key: string,
|
||||||
|
retry: boolean
|
||||||
|
): Promise<string | null> {
|
||||||
|
const url = `${BASE}/avatars/${kind}/${id}?account_id=${account}&unique_id=${encodeURIComponent(uniqueId)}`;
|
||||||
|
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 fetchVariant(account, kind, id, uniqueId, key, false);
|
||||||
|
}
|
||||||
|
missing.add(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAvatarVariant(
|
||||||
|
kind: AvatarKind,
|
||||||
|
id: number,
|
||||||
|
uniqueId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const account = accounts.selectedId;
|
||||||
|
if (account === null) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const key = `${cacheKey(account, kind, id)}:${uniqueId}`;
|
||||||
|
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 = fetchVariant(account, kind, id, uniqueId, 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,70 @@
|
|||||||
|
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 interface CustomEmojiAsset {
|
||||||
|
mime: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = new Map<string, CustomEmojiAsset>();
|
||||||
|
const missing = new Set<string>();
|
||||||
|
const inflight = new Map<string, Promise<CustomEmojiAsset | null>>();
|
||||||
|
|
||||||
|
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 fetchEmoji(
|
||||||
|
account: number,
|
||||||
|
id: string,
|
||||||
|
key: string,
|
||||||
|
retry: boolean
|
||||||
|
): Promise<CustomEmojiAsset | null> {
|
||||||
|
const url = `${BASE}/custom-emoji/${id}?account_id=${account}`;
|
||||||
|
const response = await fetch(url, { headers: authHeaders() });
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const asset = { url: URL.createObjectURL(blob), mime: blob.type };
|
||||||
|
ready.set(key, asset);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
if (response.status === 409 && retry) {
|
||||||
|
await delay(RETRY_DELAY);
|
||||||
|
return fetchEmoji(account, id, key, false);
|
||||||
|
}
|
||||||
|
missing.add(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCustomEmoji(id: string): Promise<CustomEmojiAsset | null> {
|
||||||
|
const account = accounts.selectedId;
|
||||||
|
if (account === null) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const key = `${account}:${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 = fetchEmoji(account, id, key, true).finally(() => {
|
||||||
|
inflight.delete(key);
|
||||||
|
});
|
||||||
|
inflight.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { request } from "$lib/api/client";
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
Alert,
|
||||||
|
Annotation,
|
||||||
|
AvatarHistoryView,
|
||||||
|
CallbackView,
|
||||||
|
CaptureToggles,
|
||||||
|
Chat,
|
||||||
|
ChatLinkView,
|
||||||
|
DayCount,
|
||||||
|
Folder,
|
||||||
|
JobStatus,
|
||||||
|
JobView,
|
||||||
|
LinkView,
|
||||||
|
MediaVersion,
|
||||||
|
MediaView,
|
||||||
|
MessageAt,
|
||||||
|
MessageVersion,
|
||||||
|
MessageView,
|
||||||
|
PeerHistoryView,
|
||||||
|
PeerView,
|
||||||
|
PinnedView,
|
||||||
|
PolicyChatKind,
|
||||||
|
PolicyCreate,
|
||||||
|
PolicyRecord,
|
||||||
|
PresenceHourly,
|
||||||
|
PresenceSample,
|
||||||
|
ReactionView,
|
||||||
|
ResponseStats,
|
||||||
|
SearchHit,
|
||||||
|
StoryView,
|
||||||
|
VolumeBucket,
|
||||||
|
Watch,
|
||||||
|
} 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 listPolicies(): Promise<PolicyRecord[]> {
|
||||||
|
return request<PolicyRecord[]>("/policy", { account: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicy(body: PolicyCreate): Promise<PolicyRecord> {
|
||||||
|
return request<PolicyRecord>("/policy", {
|
||||||
|
method: "POST",
|
||||||
|
body: { ...body, account_id: accounts.selectedId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePolicy(
|
||||||
|
id: number,
|
||||||
|
toggles: CaptureToggles
|
||||||
|
): Promise<PolicyRecord> {
|
||||||
|
return request<PolicyRecord>(`/policy/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: toggles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePolicy(id: number): Promise<void> {
|
||||||
|
return request<void>(`/policy/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectivePolicy(query: {
|
||||||
|
chat_id: number;
|
||||||
|
is_bot?: boolean;
|
||||||
|
is_contact?: boolean | null;
|
||||||
|
kind: PolicyChatKind;
|
||||||
|
}): Promise<CaptureToggles> {
|
||||||
|
return request<CaptureToggles>("/policy/effective", {
|
||||||
|
account: true,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMessages(
|
||||||
|
chatId: number,
|
||||||
|
options: Page & {
|
||||||
|
include_deleted?: boolean;
|
||||||
|
before_id?: number;
|
||||||
|
after_id?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<MessageView[]> {
|
||||||
|
return request<MessageView[]>(`/chats/${chatId}/messages`, {
|
||||||
|
account: true,
|
||||||
|
query: { ...options },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPresence(
|
||||||
|
peerId: number
|
||||||
|
): Promise<PresenceSample | null> {
|
||||||
|
return request<PresenceSample | null>("/presence/current", {
|
||||||
|
account: true,
|
||||||
|
query: { peer_id: peerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPresenceHistory(
|
||||||
|
peerId: number,
|
||||||
|
page: Page = {}
|
||||||
|
): Promise<PresenceSample[]> {
|
||||||
|
return request<PresenceSample[]>("/presence", {
|
||||||
|
account: true,
|
||||||
|
query: { peer_id: peerId, ...page },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
|
||||||
|
return request<PresenceHourly[]>("/presence/hourly", {
|
||||||
|
account: true,
|
||||||
|
query: { peer_id: peerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageVolume(
|
||||||
|
chatId: number,
|
||||||
|
days = 90
|
||||||
|
): Promise<VolumeBucket[]> {
|
||||||
|
return request<VolumeBucket[]>("/analytics/volume", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: chatId, days },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResponseStats(chatId: number): Promise<ResponseStats> {
|
||||||
|
return request<ResponseStats>("/analytics/response-time", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: chatId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPinned(chatId: number): Promise<PinnedView | null> {
|
||||||
|
return request<PinnedView | null>(`/chats/${chatId}/pinned`, {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getPeerHistory(peerId: number): Promise<PeerHistoryView[]> {
|
||||||
|
return request<PeerHistoryView[]>(`/peers/${peerId}/history`, {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvatarHistory(
|
||||||
|
kind: "peer" | "chat",
|
||||||
|
id: number
|
||||||
|
): Promise<AvatarHistoryView[]> {
|
||||||
|
return request<AvatarHistoryView[]>(`/avatars/${kind}/${id}/history`, {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatMedia(
|
||||||
|
chatId: number,
|
||||||
|
kinds: string[],
|
||||||
|
page: { limit?: number; offset?: number } = {}
|
||||||
|
): Promise<MediaView[]> {
|
||||||
|
return request<MediaView[]>(`/chats/${chatId}/media`, {
|
||||||
|
account: true,
|
||||||
|
query: { kinds: kinds.join(","), ...page },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatLinks(
|
||||||
|
chatId: number,
|
||||||
|
page: { limit?: number; offset?: number } = {}
|
||||||
|
): Promise<ChatLinkView[]> {
|
||||||
|
return request<ChatLinkView[]>(`/chats/${chatId}/links`, {
|
||||||
|
account: true,
|
||||||
|
query: { ...page },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageReactions(
|
||||||
|
chatId: number,
|
||||||
|
messageId: number
|
||||||
|
): Promise<ReactionView[]> {
|
||||||
|
return request<ReactionView[]>(`/messages/${chatId}/${messageId}/reactions`, {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageCallbacks(
|
||||||
|
chatId: number,
|
||||||
|
messageId: number
|
||||||
|
): Promise<CallbackView[]> {
|
||||||
|
return request<CallbackView[]>(`/messages/${chatId}/${messageId}/callbacks`, {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageLinks(
|
||||||
|
chatId: number,
|
||||||
|
messageId: number
|
||||||
|
): Promise<LinkView[]> {
|
||||||
|
return request<LinkView[]>(`/messages/${chatId}/${messageId}/links`, {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatCalendar(chatId: number): Promise<DayCount[]> {
|
||||||
|
return request<DayCount[]>(`/chats/${chatId}/calendar`, { account: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageAt(chatId: number, date: string): Promise<MessageAt> {
|
||||||
|
return request<MessageAt>(`/chats/${chatId}/message-at`, {
|
||||||
|
account: true,
|
||||||
|
query: { date },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStories(
|
||||||
|
peerId: number,
|
||||||
|
page: Page = {}
|
||||||
|
): Promise<StoryView[]> {
|
||||||
|
return request<StoryView[]>("/stories", {
|
||||||
|
account: true,
|
||||||
|
query: { peer_id: peerId, ...page },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 listJobs(status?: JobStatus): Promise<JobView[]> {
|
||||||
|
return request<JobView[]>("/jobs", {
|
||||||
|
account: true,
|
||||||
|
query: status ? { status } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueBackfill(
|
||||||
|
chatId: number,
|
||||||
|
media: boolean
|
||||||
|
): Promise<{ job_id: number }> {
|
||||||
|
return request<{ job_id: number }>("/backfill", {
|
||||||
|
method: "POST",
|
||||||
|
body: { account_id: accounts.selectedId, chat_id: chatId, media },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncDialogs(): Promise<{ job_id: number }> {
|
||||||
|
return request<{ job_id: number }>("/dialogs/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: { account_id: accounts.selectedId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 searchMessages(
|
||||||
|
query: string,
|
||||||
|
options: Page & { chat_id?: number } = {}
|
||||||
|
): Promise<SearchHit[]> {
|
||||||
|
return request<SearchHit[]>("/search", {
|
||||||
|
account: true,
|
||||||
|
query: { query, ...options },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWatches(): Promise<Watch[]> {
|
||||||
|
return request<Watch[]>("/watches", { account: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWatch(
|
||||||
|
kind: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<Watch> {
|
||||||
|
return request<Watch>("/watches", {
|
||||||
|
method: "POST",
|
||||||
|
body: { account_id: accounts.selectedId, kind, params, enabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWatch(
|
||||||
|
id: number,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<Watch> {
|
||||||
|
return request<Watch>(`/watches/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: { params, enabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWatch(id: number): Promise<void> {
|
||||||
|
return request<void>(`/watches/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAlerts(
|
||||||
|
options: Page & { seen?: boolean } = {}
|
||||||
|
): Promise<Alert[]> {
|
||||||
|
return request<Alert[]>("/alerts", { account: true, query: { ...options } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAlertSeen(id: number): Promise<void> {
|
||||||
|
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAnnotations(
|
||||||
|
options: { chatId?: number; messageId?: number } = {}
|
||||||
|
): Promise<Annotation[]> {
|
||||||
|
return request<Annotation[]>("/annotations", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: options.chatId, message_id: options.messageId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnnotation(
|
||||||
|
chatId: number,
|
||||||
|
messageId: number,
|
||||||
|
text: string
|
||||||
|
): Promise<Annotation> {
|
||||||
|
return request<Annotation>("/annotations", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
account_id: accounts.selectedId,
|
||||||
|
chat_id: chatId,
|
||||||
|
message_id: messageId,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAnnotation(
|
||||||
|
id: number,
|
||||||
|
text: string
|
||||||
|
): Promise<Annotation> {
|
||||||
|
return request<Annotation>(`/annotations/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: { text },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAnnotation(id: number): Promise<void> {
|
||||||
|
return request<void>(`/annotations/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
@@ -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,56 @@
|
|||||||
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
|
import { auth } from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||||
|
|
||||||
|
const ready = new Map<string, string>();
|
||||||
|
const missing = new Set<string>();
|
||||||
|
const inflight = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStoryMedia(
|
||||||
|
account: number,
|
||||||
|
peerId: number,
|
||||||
|
storyId: number,
|
||||||
|
key: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const url = `${BASE}/stories/${peerId}/${storyId}/media?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;
|
||||||
|
}
|
||||||
|
missing.add(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStoryMedia(
|
||||||
|
peerId: number,
|
||||||
|
storyId: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
const account = accounts.selectedId;
|
||||||
|
if (account === null) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const key = `${account}:${peerId}:${storyId}`;
|
||||||
|
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 = fetchStoryMedia(account, peerId, storyId, key).finally(() => {
|
||||||
|
inflight.delete(key);
|
||||||
|
});
|
||||||
|
inflight.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
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 PinnedView {
|
||||||
|
media_kind: string | null;
|
||||||
|
message_id: number;
|
||||||
|
sender_name: string | null;
|
||||||
|
text: string | 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[];
|
||||||
|
read: boolean;
|
||||||
|
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 AvatarHistoryView {
|
||||||
|
downloaded: boolean;
|
||||||
|
first_seen_at: string;
|
||||||
|
unique_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatLinkView {
|
||||||
|
date: string | null;
|
||||||
|
kind: string;
|
||||||
|
message_id: number;
|
||||||
|
url: string;
|
||||||
|
web_site_name: string | null;
|
||||||
|
web_title: string | null;
|
||||||
|
web_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayCount {
|
||||||
|
count: number;
|
||||||
|
day: string;
|
||||||
|
outgoing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactionView {
|
||||||
|
added_at: string;
|
||||||
|
peer_id: number;
|
||||||
|
reaction: string;
|
||||||
|
removed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallbackView {
|
||||||
|
data: string | null;
|
||||||
|
label: string | null;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkView {
|
||||||
|
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 MessageAt {
|
||||||
|
date: string;
|
||||||
|
message_id: 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 VolumeBucket {
|
||||||
|
bucket: string;
|
||||||
|
incoming: number;
|
||||||
|
outgoing: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseStats {
|
||||||
|
mine_count: number;
|
||||||
|
mine_median_seconds: number | null;
|
||||||
|
their_count: number;
|
||||||
|
their_median_seconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 type PolicyChatKind = "dm" | "group" | "channel";
|
||||||
|
|
||||||
|
export interface PolicyCreate extends CaptureToggles {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveMessageEvent {
|
||||||
|
message: MessageView;
|
||||||
|
type: "message" | "edit" | "reaction";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveDeleteEvent {
|
||||||
|
chat_id: number | null;
|
||||||
|
message_ids: number[];
|
||||||
|
type: "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LivePresenceEvent {
|
||||||
|
peer_id: number;
|
||||||
|
sample: PresenceSample | null;
|
||||||
|
type: "presence";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveReceiptEvent {
|
||||||
|
chat_id: number;
|
||||||
|
read_up_to: number;
|
||||||
|
type: "receipt";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiveEvent =
|
||||||
|
| LiveMessageEvent
|
||||||
|
| LiveDeleteEvent
|
||||||
|
| LivePresenceEvent
|
||||||
|
| LiveReceiptEvent;
|
||||||
@@ -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,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MessageView } from "$lib/api/types";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { accountName, peerName } from "$lib/format/peer";
|
||||||
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
|
import { peers } from "$lib/stores/peers.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: MessageView;
|
||||||
|
onjump: (messageId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, onjump }: Props = $props();
|
||||||
|
|
||||||
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
|
|
||||||
|
function nameOf(id: number | null): string {
|
||||||
|
if (id === null) {
|
||||||
|
return "Someone";
|
||||||
|
}
|
||||||
|
if (id === ownId) {
|
||||||
|
return accounts.selected ? accountName(accounts.selected) : "You";
|
||||||
|
}
|
||||||
|
const peer = peers.get(id);
|
||||||
|
return peer ? peerName(peer) : String(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationOf(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = $derived(nameOf(message.sender_id));
|
||||||
|
|
||||||
|
const text = $derived.by(() => {
|
||||||
|
const service = message.service;
|
||||||
|
if (!service) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const members = service.member_ids ?? [];
|
||||||
|
switch (service.kind) {
|
||||||
|
case "new_chat_members": {
|
||||||
|
const selfJoin =
|
||||||
|
members.length === 1 && members[0] === message.sender_id;
|
||||||
|
if (selfJoin) {
|
||||||
|
return `${actor} joined the group`;
|
||||||
|
}
|
||||||
|
const names = members.map(nameOf).join(", ");
|
||||||
|
return names ? `${actor} added ${names}` : `${actor} joined the group`;
|
||||||
|
}
|
||||||
|
case "left_chat_members":
|
||||||
|
return `${actor} left the group`;
|
||||||
|
case "pinned_message":
|
||||||
|
return `${actor} pinned a message`;
|
||||||
|
case "group_chat_created":
|
||||||
|
return `${actor} created the group`;
|
||||||
|
case "channel_chat_created":
|
||||||
|
return "Channel created";
|
||||||
|
case "phone_call_ended":
|
||||||
|
return service.duration === null
|
||||||
|
? "Call ended"
|
||||||
|
: `Call ended · ${durationOf(service.duration)}`;
|
||||||
|
case "phone_call_started":
|
||||||
|
return "Call started";
|
||||||
|
case "poll_option_added":
|
||||||
|
return `${actor} added a poll option`;
|
||||||
|
case "new_chat_title":
|
||||||
|
return `${actor} changed the group name`;
|
||||||
|
case "new_chat_photo":
|
||||||
|
return `${actor} changed the group photo`;
|
||||||
|
case "delete_chat_photo":
|
||||||
|
return `${actor} removed the group photo`;
|
||||||
|
default:
|
||||||
|
return service.kind.replace(/_/g, " ") || "Service message";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinned = $derived(
|
||||||
|
message.service?.kind === "pinned_message" &&
|
||||||
|
message.service.pinned_message_id !== null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ActionMessage">
|
||||||
|
{#if pinned}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill action"
|
||||||
|
onclick={() => {
|
||||||
|
const id = message.service?.pinned_message_id;
|
||||||
|
if (id != null) {
|
||||||
|
onjump(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="pin" size="0.8125rem" />
|
||||||
|
<span>{text}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="pill">{text}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ActionMessage {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3125rem;
|
||||||
|
|
||||||
|
max-width: min(30rem, 85%);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-white);
|
||||||
|
|
||||||
|
background-color: var(--color-default-shadow);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.pill {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.pill:hover {
|
||||||
|
background-color: var(--color-default-shadow-hover, var(--color-default-shadow));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
enqueueBackfill,
|
||||||
|
getCurrentPresence,
|
||||||
|
getPeer,
|
||||||
|
} from "$lib/api/endpoints";
|
||||||
|
import type { PeerView, PresenceSample } from "$lib/api/types";
|
||||||
|
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
|
||||||
|
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { peerName } from "$lib/format/peer";
|
||||||
|
import { formatPresence } from "$lib/format/presence";
|
||||||
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
|
import { chats } from "$lib/stores/chats.svelte";
|
||||||
|
import { events } from "$lib/stores/events.svelte";
|
||||||
|
import { toasts } from "$lib/stores/toasts.svelte";
|
||||||
|
import { ui } from "$lib/stores/ui.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);
|
||||||
|
let presence = $state<PresenceSample | null>(null);
|
||||||
|
let backfilling = $state(false);
|
||||||
|
|
||||||
|
async function backfill() {
|
||||||
|
if (backfilling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backfilling = true;
|
||||||
|
try {
|
||||||
|
await enqueueBackfill(chatId, true);
|
||||||
|
toasts.success("Бэкфилл запущен");
|
||||||
|
} catch {
|
||||||
|
toasts.error("Не удалось запустить бэкфилл");
|
||||||
|
} finally {
|
||||||
|
backfilling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (accounts.selectedId === null || !isDm) {
|
||||||
|
presence = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
presence = null;
|
||||||
|
getCurrentPresence(chatId)
|
||||||
|
.then((result) => {
|
||||||
|
if (active) {
|
||||||
|
presence = result;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
presence = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const unsub = events.subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "presence" &&
|
||||||
|
event.peer_id === chatId &&
|
||||||
|
event.sample
|
||||||
|
) {
|
||||||
|
presence = event.sample;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackTitle = $derived(
|
||||||
|
chat?.title ?? (isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
|
||||||
|
);
|
||||||
|
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||||
|
const subtitle = $derived.by(() => {
|
||||||
|
if (isDm) {
|
||||||
|
if (presence) {
|
||||||
|
return formatPresence(presence);
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
<ContextMenu>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
type="button"
|
||||||
|
class="peek"
|
||||||
|
onclick={() => ui.openPanel("profile")}
|
||||||
|
aria-label="Открыть профиль"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
class:online={isDm && presence?.status === "online"}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet menu()}
|
||||||
|
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
|
||||||
|
>Открыть профиль</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon="play-story"
|
||||||
|
onselect={() => ui.openPanel("stories")}
|
||||||
|
>Сторис</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu>
|
||||||
|
<div class="actions">
|
||||||
|
<Button
|
||||||
|
variant="translucent"
|
||||||
|
round
|
||||||
|
smaller
|
||||||
|
onclick={() => ui.openPanel("search")}
|
||||||
|
aria-label="Поиск в чате"
|
||||||
|
>
|
||||||
|
<Icon name="search" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="translucent"
|
||||||
|
round
|
||||||
|
smaller
|
||||||
|
onclick={() => ui.openPanel("presence")}
|
||||||
|
aria-label="Аналитика"
|
||||||
|
>
|
||||||
|
<Icon name="stats" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="translucent"
|
||||||
|
round
|
||||||
|
smaller
|
||||||
|
loading={backfilling}
|
||||||
|
onclick={backfill}
|
||||||
|
aria-label="Скачать историю"
|
||||||
|
>
|
||||||
|
<Icon name="cloud-download" />
|
||||||
|
</Button>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
|
||||||
|
&.online {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<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))
|
||||||
|
);
|
||||||
|
|
||||||
|
const SCROLL_THRESHOLD = 600;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (accounts.selectedId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chats.load().catch(() => toasts.error("Failed to load chats"));
|
||||||
|
folders.load().catch(() => toasts.error("Failed to load folders"));
|
||||||
|
});
|
||||||
|
|
||||||
|
function onScroll(event: Event) {
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
|
||||||
|
chats.loadMore().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chat-list custom-scroll" onscroll={onScroll}>
|
||||||
|
{#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 && !chats.hasMore}
|
||||||
|
<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,197 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { ripple } from "$lib/actions/ripple";
|
||||||
|
import type { Chat } from "$lib/api/types";
|
||||||
|
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||||
|
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
|
||||||
|
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
|
||||||
|
import { formatListDate } from "$lib/format/datetime";
|
||||||
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
|
import { peers } from "$lib/stores/peers.svelte";
|
||||||
|
import { type RightPanel, ui } from "$lib/stores/ui.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chat: Chat;
|
||||||
|
onclick: () => void;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chat, selected, onclick }: Props = $props();
|
||||||
|
|
||||||
|
async function openWith(panel: RightPanel) {
|
||||||
|
await goto(`/app/${chat.chat_id}`);
|
||||||
|
ui.openPanel(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
chat.title ??
|
||||||
|
(chat.chat_id > 0 ? "Удалённый аккаунт" : `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>
|
||||||
|
|
||||||
|
<ContextMenu>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
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>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet menu()}
|
||||||
|
<ContextMenuItem icon="open-in-new-tab" onselect={onclick}
|
||||||
|
>Открыть</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem icon="info" onselect={() => openWith("profile")}
|
||||||
|
>Профиль</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem icon="search" onselect={() => openWith("search")}
|
||||||
|
>Поиск в чате</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem icon="stats" onselect={() => openWith("presence")}
|
||||||
|
>Аналитика</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
|
||||||
|
>Сторис</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<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,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ContactView } from "$lib/api/types";
|
||||||
|
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contact: ContactView;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contact }: Props = $props();
|
||||||
|
|
||||||
|
const name = $derived(
|
||||||
|
[contact.first_name, contact.last_name].filter(Boolean).join(" ") ||
|
||||||
|
"Contact"
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="Contact">
|
||||||
|
<Avatar {name} colorKey={contact.user_id ?? 0} size={2.5} />
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">{name}</div>
|
||||||
|
{#if contact.phone_number}
|
||||||
|
<div class="phone">{contact.phone_number}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.Contact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
|
||||||
|
min-width: 12rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type CustomEmojiAsset,
|
||||||
|
loadCustomEmoji,
|
||||||
|
} from "$lib/api/custom-emoji";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, size = 1.25 }: Props = $props();
|
||||||
|
|
||||||
|
let asset = $state<CustomEmojiAsset | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
asset = null;
|
||||||
|
let active = true;
|
||||||
|
loadCustomEmoji(id).then((resolved) => {
|
||||||
|
if (active) {
|
||||||
|
asset = resolved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isVideo = $derived(asset?.mime.startsWith("video/") ?? false);
|
||||||
|
const isImage = $derived(asset?.mime.startsWith("image/") ?? false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="CustomEmoji" style="--emoji-size: {size}rem;">
|
||||||
|
{#if asset && isVideo}
|
||||||
|
<video src={asset.url} autoplay loop muted playsinline></video>
|
||||||
|
{:else if asset && isImage}
|
||||||
|
<img src={asset.url} alt="emoji">
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder">🧩</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.CustomEmoji {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--emoji-size);
|
||||||
|
height: var(--emoji-size);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
font-size: calc(var(--emoji-size) * 0.85);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</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,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InlineButton } from "$lib/api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: InlineButton[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { rows }: Props = $props();
|
||||||
|
|
||||||
|
let copied = $state<string | null>(null);
|
||||||
|
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
async function copy(key: string, data: string): Promise<void> {
|
||||||
|
await navigator.clipboard.writeText(data);
|
||||||
|
copied = key;
|
||||||
|
clearTimeout(resetTimer);
|
||||||
|
resetTimer = setTimeout(() => {
|
||||||
|
copied = null;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="InlineButtons">
|
||||||
|
{#each rows as row, rowIndex (rowIndex)}
|
||||||
|
<div class="row">
|
||||||
|
{#each row as button, colIndex (colIndex)}
|
||||||
|
{@const key = `${rowIndex}:${colIndex}`}
|
||||||
|
{#if button.kind === "url" && button.url}
|
||||||
|
<a class="button" href={button.url} target="_blank" rel="noopener">
|
||||||
|
<span class="label">{button.text}</span>
|
||||||
|
<span class="corner">↗</span>
|
||||||
|
</a>
|
||||||
|
{:else if button.kind === "callback" && button.data}
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => copy(key, button.data ?? "")}
|
||||||
|
>
|
||||||
|
<span class="label"
|
||||||
|
>{copied === key ? "Скопировано" : button.text}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="button static">
|
||||||
|
<span class="label">{button.text}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.InlineButtons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.1875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-messages-small);
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
background-color: var(--color-message-reaction);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: background-color 150ms;
|
||||||
|
|
||||||
|
&:hover:not(.static) {
|
||||||
|
background-color: var(--color-message-reaction-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LocationView } from "$lib/api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
location: LocationView;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { location }: Props = $props();
|
||||||
|
|
||||||
|
const hasCoords = $derived(
|
||||||
|
location.latitude !== null && location.longitude !== null
|
||||||
|
);
|
||||||
|
const mapUrl = $derived(
|
||||||
|
hasCoords
|
||||||
|
? `https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const title = $derived(location.title ?? "Location");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={mapUrl ? "a" : "div"}
|
||||||
|
class="Location"
|
||||||
|
href={mapUrl}
|
||||||
|
target={mapUrl ? "_blank" : undefined}
|
||||||
|
rel={mapUrl ? "noopener" : undefined}
|
||||||
|
>
|
||||||
|
<span class="pin">📍</span>
|
||||||
|
<div class="info">
|
||||||
|
<div class="title">{title}</div>
|
||||||
|
{#if location.address}
|
||||||
|
<div class="address">{location.address}</div>
|
||||||
|
{/if}
|
||||||
|
{#if hasCoords}
|
||||||
|
<div class="coords">{location.latitude}, {location.longitude}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</svelte:element>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.Location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
min-width: 12rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address,
|
||||||
|
.coords {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
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,432 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { MessageView } from "$lib/api/types";
|
||||||
|
import Contact from "$lib/components/Contact.svelte";
|
||||||
|
import EntityText from "$lib/components/EntityText.svelte";
|
||||||
|
import ForwardHeader from "$lib/components/ForwardHeader.svelte";
|
||||||
|
import InlineButtons from "$lib/components/InlineButtons.svelte";
|
||||||
|
import Location from "$lib/components/Location.svelte";
|
||||||
|
import MediaAlbum from "$lib/components/MediaAlbum.svelte";
|
||||||
|
import MessageMedia from "$lib/components/MessageMedia.svelte";
|
||||||
|
import MessageMeta from "$lib/components/MessageMeta.svelte";
|
||||||
|
import Poll from "$lib/components/Poll.svelte";
|
||||||
|
import Reactions from "$lib/components/Reactions.svelte";
|
||||||
|
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
|
||||||
|
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||||
|
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
|
||||||
|
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import WebPage from "$lib/components/WebPage.svelte";
|
||||||
|
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
|
||||||
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
|
import { peers } from "$lib/stores/peers.svelte";
|
||||||
|
import { toasts } from "$lib/stores/toasts.svelte";
|
||||||
|
import { ui } from "$lib/stores/ui.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 special = $derived(
|
||||||
|
Boolean(
|
||||||
|
message.web_page || message.poll || message.contact || message.location
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasCallbacks = $derived(
|
||||||
|
message.inline_buttons.some((row) =>
|
||||||
|
row.some((button) => button.kind === "callback" && button.data)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const linkCount = $derived.by(() => {
|
||||||
|
const count = message.entities.filter(
|
||||||
|
(entity) => entity.type === "url" || entity.type === "text_link"
|
||||||
|
).length;
|
||||||
|
if (count > 0) {
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
return message.web_page ? 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openReactions() {
|
||||||
|
ui.openMessagePanel("reactions", message.message_id);
|
||||||
|
}
|
||||||
|
function openCallbacks() {
|
||||||
|
ui.openMessagePanel("callbacks", message.message_id);
|
||||||
|
}
|
||||||
|
function openLinks() {
|
||||||
|
ui.openMessagePanel("links", message.message_id);
|
||||||
|
}
|
||||||
|
function openAnnotations() {
|
||||||
|
ui.openMessagePanel("annotations", message.message_id);
|
||||||
|
}
|
||||||
|
function jumpToReply() {
|
||||||
|
if (message.reply?.message_id != null) {
|
||||||
|
onjump(message.reply.message_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function copyText() {
|
||||||
|
await navigator.clipboard.writeText(message.text ?? "");
|
||||||
|
toasts.success("Текст скопирован");
|
||||||
|
}
|
||||||
|
async function copyId() {
|
||||||
|
await navigator.clipboard.writeText(String(message.message_id));
|
||||||
|
toasts.success("ID скопирован");
|
||||||
|
}
|
||||||
|
function openSenderProfile() {
|
||||||
|
if (message.sender_id !== null) {
|
||||||
|
goto(`/app/${message.sender_id}`);
|
||||||
|
ui.openPanel("profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenu>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
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.poll}
|
||||||
|
<Poll poll={message.poll} />
|
||||||
|
{/if}
|
||||||
|
{#if message.contact}
|
||||||
|
<Contact contact={message.contact} />
|
||||||
|
{/if}
|
||||||
|
{#if message.location}
|
||||||
|
<Location location={message.location} />
|
||||||
|
{/if}
|
||||||
|
{#if !special}
|
||||||
|
{#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}
|
||||||
|
{#if hasText}
|
||||||
|
<div class="text">
|
||||||
|
<EntityText
|
||||||
|
text={message.text ?? ""}
|
||||||
|
entities={message.entities}
|
||||||
|
{own}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if !(message.has_media || deleted || special)}
|
||||||
|
<div class="text empty">(no text)</div>
|
||||||
|
{/if}
|
||||||
|
{#if message.web_page}
|
||||||
|
<WebPage {message} {own} {onmedia} />
|
||||||
|
{/if}
|
||||||
|
{#if message.reactions.length}
|
||||||
|
<Reactions
|
||||||
|
reactions={message.reactions}
|
||||||
|
{own}
|
||||||
|
onclick={openReactions}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if linkCount > 0}
|
||||||
|
<button type="button" class="detail-chip" onclick={openLinks}>
|
||||||
|
<Icon name="link" size="0.875rem" />
|
||||||
|
{linkCount}
|
||||||
|
{linkCount === 1 ? "ссылка" : "ссылки"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<MessageMeta {message} {own} {onversions} />
|
||||||
|
{#if message.inline_buttons.length}
|
||||||
|
<InlineButtons rows={message.inline_buttons} />
|
||||||
|
{/if}
|
||||||
|
{#if hasCallbacks}
|
||||||
|
<button type="button" class="detail-chip" onclick={openCallbacks}>
|
||||||
|
<Icon name="bot-command" size="0.875rem" />
|
||||||
|
Callback-данные
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#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>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet menu()}
|
||||||
|
<ContextMenuItem icon="note" onselect={openAnnotations}
|
||||||
|
>Заметки</ContextMenuItem
|
||||||
|
>
|
||||||
|
{#if message.reactions.length}
|
||||||
|
<ContextMenuItem icon="heart" onselect={openReactions}
|
||||||
|
>Реакции</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if linkCount > 0}
|
||||||
|
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem>
|
||||||
|
{/if}
|
||||||
|
{#if hasCallbacks}
|
||||||
|
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
|
||||||
|
>Callback-данные</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if message.edited_at}
|
||||||
|
<ContextMenuItem icon="edit" onselect={onversions}
|
||||||
|
>Версии</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if message.reply?.message_id != null}
|
||||||
|
<ContextMenuItem icon="reply" onselect={jumpToReply}
|
||||||
|
>Перейти к ответу</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if hasText}
|
||||||
|
<ContextMenuItem icon="copy" onselect={copyText}
|
||||||
|
>Копировать текст</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<ContextMenuItem icon="hashtag" onselect={copyId}
|
||||||
|
>Копировать ID</ContextMenuItem
|
||||||
|
>
|
||||||
|
{#if isGroupChat && !own && message.sender_id !== null}
|
||||||
|
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
||||||
|
>Профиль автора</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem 0.125rem 0.375rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--color-message-reaction);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,613 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { listMessages } from "$lib/api/endpoints";
|
||||||
|
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
|
||||||
|
import type { LiveEvent, MessageView } from "$lib/api/types";
|
||||||
|
import ActionMessage from "$lib/components/ActionMessage.svelte";
|
||||||
|
import MediaViewer from "$lib/components/MediaViewer.svelte";
|
||||||
|
import MessageBubble from "$lib/components/MessageBubble.svelte";
|
||||||
|
import MessageVersions from "$lib/components/MessageVersions.svelte";
|
||||||
|
import PinnedBar from "$lib/components/PinnedBar.svelte";
|
||||||
|
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||||
|
import Icon from "$lib/components/ui/Icon.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 { events } from "$lib/stores/events.svelte";
|
||||||
|
import { peers } from "$lib/stores/peers.svelte";
|
||||||
|
import { toasts } from "$lib/stores/toasts.svelte";
|
||||||
|
import { ui } from "$lib/stores/ui.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 loadingNewer = $state(false);
|
||||||
|
let hasMore = $state(true);
|
||||||
|
let hasNewer = $state(false);
|
||||||
|
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 (message.service?.member_ids) {
|
||||||
|
for (const id of message.service.member_ids) {
|
||||||
|
ids.add(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;
|
||||||
|
hasNewer = false;
|
||||||
|
try {
|
||||||
|
const page = await listMessages(chatId, {
|
||||||
|
limit: PAGE,
|
||||||
|
offset: 0,
|
||||||
|
include_deleted: true,
|
||||||
|
});
|
||||||
|
messages = [...page].reverse();
|
||||||
|
hasMore = page.length > 0;
|
||||||
|
ensurePeers(messages);
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
updateStuck();
|
||||||
|
} catch {
|
||||||
|
toasts.error("Failed to load messages");
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlder() {
|
||||||
|
if (
|
||||||
|
loadingOlder ||
|
||||||
|
!hasMore ||
|
||||||
|
container === null ||
|
||||||
|
messages.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingOlder = true;
|
||||||
|
const el = container;
|
||||||
|
const prevHeight = el.scrollHeight;
|
||||||
|
const prevTop = el.scrollTop;
|
||||||
|
try {
|
||||||
|
const page = await listMessages(chatId, {
|
||||||
|
limit: PAGE,
|
||||||
|
before_id: messages[0].message_id,
|
||||||
|
include_deleted: true,
|
||||||
|
});
|
||||||
|
const known = new Set(messages.map((m) => m.message_id));
|
||||||
|
const fresh = page.filter((m) => !known.has(m.message_id));
|
||||||
|
hasMore = fresh.length > 0;
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
suppressAppear = true;
|
||||||
|
messages = [...[...fresh].reverse(), ...messages];
|
||||||
|
ensurePeers(fresh);
|
||||||
|
await tick();
|
||||||
|
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingOlder = false;
|
||||||
|
suppressAppear = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNewer() {
|
||||||
|
if (loadingNewer || !hasNewer || messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingNewer = true;
|
||||||
|
try {
|
||||||
|
const page = await listMessages(chatId, {
|
||||||
|
limit: PAGE,
|
||||||
|
after_id: messages.at(-1)?.message_id,
|
||||||
|
include_deleted: true,
|
||||||
|
});
|
||||||
|
const known = new Set(messages.map((m) => m.message_id));
|
||||||
|
const fresh = page.filter((m) => !known.has(m.message_id));
|
||||||
|
hasNewer = fresh.length > 0;
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
suppressAppear = true;
|
||||||
|
messages = [...messages, ...fresh];
|
||||||
|
ensurePeers(fresh);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingNewer = false;
|
||||||
|
suppressAppear = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAround(messageId: number) {
|
||||||
|
suppressAppear = true;
|
||||||
|
try {
|
||||||
|
const [older, newer] = await Promise.all([
|
||||||
|
listMessages(chatId, {
|
||||||
|
limit: PAGE,
|
||||||
|
before_id: messageId + 1,
|
||||||
|
include_deleted: true,
|
||||||
|
}),
|
||||||
|
listMessages(chatId, {
|
||||||
|
limit: PAGE,
|
||||||
|
after_id: messageId,
|
||||||
|
include_deleted: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
messages = [...[...older].reverse(), ...newer];
|
||||||
|
hasMore = older.length > 0;
|
||||||
|
hasNewer = newer.length > 0;
|
||||||
|
ensurePeers(messages);
|
||||||
|
await tick();
|
||||||
|
} finally {
|
||||||
|
suppressAppear = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToLatest() {
|
||||||
|
await loadInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
scrolling = true;
|
||||||
|
if (idleTimer) {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
}
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
scrolling = false;
|
||||||
|
}, IDLE_DELAY);
|
||||||
|
updateStuck();
|
||||||
|
if (container && container.scrollTop < SCROLL_THRESHOLD) {
|
||||||
|
loadOlder();
|
||||||
|
}
|
||||||
|
if (hasNewer && isNearBottom()) {
|
||||||
|
loadNewer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearBottom(): boolean {
|
||||||
|
if (!container) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight <
|
||||||
|
SCROLL_THRESHOLD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMessage(message: MessageView) {
|
||||||
|
messages = messages.map((m) =>
|
||||||
|
m.message_id === message.message_id ? message : m
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendMessage(message: MessageView) {
|
||||||
|
if (messages.some((m) => m.message_id === message.message_id)) {
|
||||||
|
replaceMessage(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasNewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stick = isNearBottom();
|
||||||
|
messages = [...messages, message];
|
||||||
|
ensurePeers([message]);
|
||||||
|
if (stick) {
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDelete(ids: number[]) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
messages = messages.map((m) =>
|
||||||
|
ids.includes(m.message_id) && m.deleted_at === null
|
||||||
|
? { ...m, deleted_at: now }
|
||||||
|
: m
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReceipt(readUpTo: number) {
|
||||||
|
if (ownId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messages = messages.map((m) =>
|
||||||
|
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
||||||
|
? { ...m, read: true }
|
||||||
|
: m
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLiveEvent(event: LiveEvent) {
|
||||||
|
if (event.type === "message" && event.message.chat_id === chatId) {
|
||||||
|
appendMessage(event.message);
|
||||||
|
} else if (
|
||||||
|
(event.type === "edit" || event.type === "reaction") &&
|
||||||
|
event.message.chat_id === chatId
|
||||||
|
) {
|
||||||
|
replaceMessage(event.message);
|
||||||
|
} else if (
|
||||||
|
event.type === "delete" &&
|
||||||
|
(event.chat_id === null || event.chat_id === chatId)
|
||||||
|
) {
|
||||||
|
applyDelete(event.message_ids);
|
||||||
|
} else if (event.type === "receipt" && event.chat_id === chatId) {
|
||||||
|
applyReceipt(event.read_up_to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resyncTail() {
|
||||||
|
if (messages.length === 0 || hasNewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = await listMessages(chatId, {
|
||||||
|
limit: PAGE,
|
||||||
|
offset: 0,
|
||||||
|
include_deleted: true,
|
||||||
|
});
|
||||||
|
const fresh = [...page].reverse();
|
||||||
|
const known = new Map(messages.map((m) => [m.message_id, m]));
|
||||||
|
const maxId = messages.reduce((acc, m) => Math.max(acc, m.message_id), 0);
|
||||||
|
const stick = isNearBottom();
|
||||||
|
const appended: MessageView[] = [];
|
||||||
|
for (const message of fresh) {
|
||||||
|
if (known.has(message.message_id)) {
|
||||||
|
known.set(message.message_id, message);
|
||||||
|
} else if (message.message_id > maxId) {
|
||||||
|
appended.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages = [
|
||||||
|
...messages.map((m) => known.get(m.message_id) ?? m),
|
||||||
|
...appended,
|
||||||
|
];
|
||||||
|
ensurePeers(fresh);
|
||||||
|
if (appended.length > 0 && stick) {
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jumpToTarget(messageId: number) {
|
||||||
|
if (!messages.some((m) => m.message_id === messageId)) {
|
||||||
|
await loadAround(messageId);
|
||||||
|
}
|
||||||
|
await tick();
|
||||||
|
jumpToMessage(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const target = ui.jumpTarget;
|
||||||
|
if (target === null || target.chatId !== chatId || loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.clearJump();
|
||||||
|
jumpToTarget(target.messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const unsub = events.subscribe(applyLiveEvent);
|
||||||
|
const unsubReconnect = events.onReconnect(resyncTail);
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
unsubReconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const deps = {
|
||||||
|
account: accounts.selectedId,
|
||||||
|
revision: chats.revision,
|
||||||
|
};
|
||||||
|
if (deps.account === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadInitial();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="message-pane">
|
||||||
|
<PinnedBar {chatId} onjump={jumpToMessage} />
|
||||||
|
<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}
|
||||||
|
{#if row.message.service}
|
||||||
|
<ActionMessage message={row.message} onjump={jumpToMessage} />
|
||||||
|
{:else}
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if loadingNewer}
|
||||||
|
<div class="loading-older"><Spinner /></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if hasNewer}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="to-latest"
|
||||||
|
onclick={goToLatest}
|
||||||
|
aria-label="К последним сообщениям"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-down" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MediaViewer
|
||||||
|
bind:open={mediaOpen}
|
||||||
|
bind:index={mediaIndex}
|
||||||
|
{chatId}
|
||||||
|
items={mediaItems}
|
||||||
|
/>
|
||||||
|
<MessageVersions
|
||||||
|
bind:open={versionsOpen}
|
||||||
|
{chatId}
|
||||||
|
messageId={versionsMessageId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.message-pane {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.to-latest {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.25rem;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
z-index: var(--z-sticky-date);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 16%);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 TgsSticker from "$lib/components/media/TgsSticker.svelte";
|
||||||
|
import VideoNote from "$lib/components/media/VideoNote.svelte";
|
||||||
|
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
|
||||||
|
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
|
||||||
|
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||||
|
import { toasts } from "$lib/stores/toasts.svelte";
|
||||||
|
import { ui } from "$lib/stores/ui.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>
|
||||||
|
|
||||||
|
<ContextMenu>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<div {...props} 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}
|
||||||
|
<TgsSticker url={ready.url} />
|
||||||
|
{: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>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet menu()}
|
||||||
|
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
||||||
|
>Открыть</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon="recent"
|
||||||
|
onselect={() => ui.openMessagePanel("versions", message.message_id)}
|
||||||
|
>Версии медиа</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem icon="download" onselect={queue}>Скачать</ContextMenuItem>
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MessageView } from "$lib/api/types";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { formatTime } from "$lib/format/datetime";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: MessageView;
|
||||||
|
onversions?: () => void;
|
||||||
|
own?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, onversions, own = false }: 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>
|
||||||
|
{#if own}
|
||||||
|
<Icon
|
||||||
|
class="ticks"
|
||||||
|
name={message.read ? "message-read" : "check"}
|
||||||
|
size="1rem"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</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 { getPinned } from "$lib/api/endpoints";
|
||||||
|
import type { PinnedView } from "$lib/api/types";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { mediaKindLabel } from "$lib/format/media";
|
||||||
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId: number;
|
||||||
|
onjump: (messageId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chatId, onjump }: Props = $props();
|
||||||
|
|
||||||
|
let pinned = $state<PinnedView | null>(null);
|
||||||
|
|
||||||
|
const preview = $derived(
|
||||||
|
pinned
|
||||||
|
? (pinned.text ??
|
||||||
|
(pinned.media_kind
|
||||||
|
? mediaKindLabel(pinned.media_kind)
|
||||||
|
: "Pinned message"))
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const chat = chatId;
|
||||||
|
const account = accounts.selectedId;
|
||||||
|
if (account === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pinned = null;
|
||||||
|
let active = true;
|
||||||
|
getPinned(chat)
|
||||||
|
.then((result) => {
|
||||||
|
if (active) {
|
||||||
|
pinned = result;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
pinned = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pinned}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="PinnedBar"
|
||||||
|
onclick={() => pinned && onjump(pinned.message_id)}
|
||||||
|
>
|
||||||
|
<Icon name="pin" size="1.125rem" />
|
||||||
|
<span class="body">
|
||||||
|
<span class="label">Pinned message</span>
|
||||||
|
<span class="preview">{preview}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.PinnedBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
|
||||||
|
text-align: start;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:global(.icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-hover, var(--color-background));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PollView } from "$lib/api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
poll: PollView;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { poll }: Props = $props();
|
||||||
|
|
||||||
|
const kindLabel = $derived(poll.quiz ? "Quiz" : "Poll");
|
||||||
|
const visibility = $derived(poll.anonymous ? "Anonymous" : "Public");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="Poll">
|
||||||
|
<div class="question">{poll.question}</div>
|
||||||
|
<div class="meta">
|
||||||
|
{kindLabel}
|
||||||
|
· {visibility}{poll.closed ? " · Closed" : ""}
|
||||||
|
</div>
|
||||||
|
{#each poll.options as option, index (index)}
|
||||||
|
<div class="option" class:correct={option.correct}>
|
||||||
|
<div class="head">
|
||||||
|
<span class="text">{option.text}</span>
|
||||||
|
<span class="pct">{option.vote_percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar">
|
||||||
|
<div class="fill" style="width: {option.vote_percentage}%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="footer">{poll.total_voter_count} votes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.Poll {
|
||||||
|
min-width: 16rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 0.25rem;
|
||||||
|
margin-top: 0.1875rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--color-message-reaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correct .fill {
|
||||||
|
background-color: var(--color-text-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ReactionCount } from "$lib/api/types";
|
||||||
|
import CustomEmoji from "$lib/components/CustomEmoji.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onclick?: () => void;
|
||||||
|
own: boolean;
|
||||||
|
reactions: ReactionCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { reactions, own, onclick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="Reactions" class:own>
|
||||||
|
{#each reactions as reaction, index (reaction.custom_emoji_id ?? reaction.emoji ?? index)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reaction"
|
||||||
|
class:chosen={reaction.chosen}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{#if reaction.custom_emoji_id}
|
||||||
|
<CustomEmoji id={reaction.custom_emoji_id} size={1.25} />
|
||||||
|
{:else}
|
||||||
|
<span class="emoji">{reaction.emoji ?? "❓"}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="count">{reaction.count}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.Reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1875rem;
|
||||||
|
|
||||||
|
height: 1.625rem;
|
||||||
|
padding: 0 0.4375rem 0 0.375rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--color-message-reaction);
|
||||||
|
|
||||||
|
.own & {
|
||||||
|
background-color: var(--color-message-reaction-own);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.chosen {
|
||||||
|
color: var(--color-white);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user