feat: web UI chat render, panels, presence + analytics
This commit is contained in:
@@ -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")
|
||||
@@ -11,12 +11,16 @@ from starlette.applications import Starlette
|
||||
|
||||
from api.auth import BearerAuthMiddleware
|
||||
from api.mcp.server import mcp
|
||||
from api.realtime import hub
|
||||
from api.routers import (
|
||||
accounts,
|
||||
analytics,
|
||||
annotations,
|
||||
avatars,
|
||||
backfill,
|
||||
chats,
|
||||
custom_emoji,
|
||||
events,
|
||||
folders,
|
||||
media,
|
||||
peers,
|
||||
@@ -39,7 +43,10 @@ mcp_app = mcp.http_app(path="/")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
|
||||
pool = await container.get(asyncpg.Pool)
|
||||
await hub.start(pool)
|
||||
yield
|
||||
await hub.stop()
|
||||
await app_.state.dishka_container.close()
|
||||
|
||||
|
||||
@@ -59,6 +66,7 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
|
||||
|
||||
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(policy.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(backfill.router)
|
||||
@@ -66,8 +74,10 @@ app.include_router(search.router)
|
||||
app.include_router(chats.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(presence.router)
|
||||
app.include_router(events.router)
|
||||
app.include_router(peers.router)
|
||||
app.include_router(annotations.router)
|
||||
app.include_router(watches.router)
|
||||
|
||||
@@ -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,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)
|
||||
@@ -24,6 +24,10 @@ class FetchMediaRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class SyncDialogsRequest(BaseModel):
|
||||
account_id: int
|
||||
|
||||
|
||||
class EnqueueResponse(BaseModel):
|
||||
job_id: int
|
||||
|
||||
@@ -78,6 +82,14 @@ async def enqueue_fetch_media(
|
||||
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")
|
||||
async def list_jobs(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
|
||||
@@ -13,7 +13,9 @@ from utils.read.models import (
|
||||
MessageVersionView,
|
||||
MessageView,
|
||||
Page,
|
||||
PinnedView,
|
||||
)
|
||||
from utils.read.pinned import get_pinned
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["chats"], route_class=DishkaRoute)
|
||||
|
||||
@@ -55,6 +57,13 @@ async def chat_history(
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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")
|
||||
async def presence_hourly(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
|
||||
@@ -3,6 +3,7 @@ from pyrogram.types import Message
|
||||
from userbot import PyroClient
|
||||
from userbot.modules.capture import repository
|
||||
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
|
||||
@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)
|
||||
if 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():
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ from userbot.modules.capture import repository
|
||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||
from userbot.modules.capture.message import sender_id
|
||||
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
|
||||
@PyroClient.on_edited_message()
|
||||
@@ -32,8 +33,13 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
||||
has_media=message.media is not None,
|
||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||
)
|
||||
if changed and message.media is not None:
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ from userbot.modules.capture import capture_message
|
||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||
from userbot.modules.stt import is_transcribable
|
||||
from userbot.modules.stt.gate import safe_transcribe
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
|
||||
@PyroClient.on_message()
|
||||
@@ -18,6 +19,9 @@ async def on_message(client: PyroClient, message: Message) -> None:
|
||||
if not toggles.messages:
|
||||
return
|
||||
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 (
|
||||
toggles.stt
|
||||
and is_transcribable(message)
|
||||
|
||||
@@ -4,6 +4,7 @@ from pyrogram.types import User
|
||||
|
||||
from userbot import PyroClient
|
||||
from userbot.modules.presence import repository
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
|
||||
@PyroClient.on_user_status()
|
||||
@@ -22,6 +23,7 @@ async def on_user_status(client: PyroClient, user: User) -> None:
|
||||
str(user.raw),
|
||||
)
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ from pyrogram import raw, utils
|
||||
from userbot import PyroClient
|
||||
from userbot.modules.capture import repository
|
||||
from userbot.modules.capture.chat_meta import meta_from_peer
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
HANDLES = (raw.types.UpdateMessageReactions,)
|
||||
|
||||
@@ -47,3 +48,10 @@ async def handle(
|
||||
await repository.sync_reactions(
|
||||
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.modules.read_receipts import repository
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
HANDLES = (raw.types.UpdateReadHistoryOutbox,)
|
||||
|
||||
@@ -24,3 +25,6 @@ async def handle(
|
||||
update.max_id,
|
||||
str(update),
|
||||
)
|
||||
await notify_bg_event(
|
||||
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=update.max_id
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -2,8 +2,18 @@ from userbot.modules.jobs.handlers import (
|
||||
backfill,
|
||||
enrich_chat,
|
||||
fetch_avatar,
|
||||
fetch_custom_emoji,
|
||||
fetch_media,
|
||||
sync_dialogs,
|
||||
transcribe,
|
||||
)
|
||||
|
||||
__all__ = ["backfill", "enrich_chat", "fetch_avatar", "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.jobs.context import JobContext
|
||||
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)
|
||||
processed = ctx.job.progress.get("processed", 0)
|
||||
kwargs = {"max_id": max_id} if max_id else {}
|
||||
async for message in client.get_chat_history(chat_id, **kwargs):
|
||||
await capture_message(client, message, capture, toggles)
|
||||
processed += 1
|
||||
if processed % SAVE_EVERY == 0:
|
||||
next_max = message.id - 1
|
||||
await ctx.save_cursor({"max_id": next_max})
|
||||
await ctx.report_progress({"processed": processed, "max_id": next_max})
|
||||
try:
|
||||
async for message in client.get_chat_history(chat_id, **kwargs):
|
||||
await capture_message(client, message, capture, toggles)
|
||||
processed += 1
|
||||
if processed % SAVE_EVERY == 0:
|
||||
next_max = message.id - 1
|
||||
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})
|
||||
|
||||
@@ -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})
|
||||
@@ -20,11 +20,20 @@ _MEDIA_ATTRS = (
|
||||
)
|
||||
|
||||
|
||||
_WEB_PAGE_ATTRS = ("photo", "video", "animation", "document", "audio")
|
||||
|
||||
|
||||
def media_object(message: Message) -> tuple[str | None, Any]:
|
||||
for attr in _MEDIA_ATTRS:
|
||||
obj = getattr(message, attr, None)
|
||||
if obj is not None:
|
||||
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
|
||||
|
||||
|
||||
@@ -70,7 +79,8 @@ async def capture_media( # noqa: PLR0913
|
||||
file_size = existing["file_size"]
|
||||
downloaded = True
|
||||
else:
|
||||
buffer = await client.download_media(message, in_memory=True)
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyrogram import Client, raw
|
||||
from pyrogram.types import User
|
||||
from pyrogram.types import Chat, User
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -54,3 +54,18 @@ def snapshot_from_high_level(
|
||||
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.jobs import JobConsumer
|
||||
from utils.env import env
|
||||
from utils.jobs import enqueue
|
||||
from utils.logging import logger, setup_logging
|
||||
from utils.read.watches import WATCHES_CHANGED_CHANNEL
|
||||
from utils.storage import ContentAddressedStorage
|
||||
@@ -72,6 +73,17 @@ async def _setup_capture(
|
||||
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(
|
||||
clients: list[PyroClient], tasks: set[asyncio.Task]
|
||||
) -> asyncpg.Connection:
|
||||
@@ -134,6 +146,7 @@ async def runner() -> None:
|
||||
await _setup_capture(pool, client, account_id, storage)
|
||||
consumer = JobConsumer(client, pool, account_id)
|
||||
consumer_tasks.append(asyncio.create_task(consumer.run()))
|
||||
await _enqueue_sync_dialogs(pool, account_id)
|
||||
|
||||
if clients:
|
||||
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()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -3,6 +3,12 @@ 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 "
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncpg
|
||||
|
||||
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,
|
||||
@@ -8,6 +9,7 @@ from utils.read.models import (
|
||||
MessageView,
|
||||
Page,
|
||||
)
|
||||
from utils.read.read_receipts import read_up_to
|
||||
|
||||
_MESSAGE_COLS = (
|
||||
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
|
||||
@@ -52,35 +54,43 @@ async def list_chats(
|
||||
pool: asyncpg.Pool, account_id: int, page: Page
|
||||
) -> list[ChatListItem]:
|
||||
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 "
|
||||
"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 "
|
||||
"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 "
|
||||
"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 "
|
||||
"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, "
|
||||
"EXISTS (SELECT 1 FROM avatars a "
|
||||
"WHERE a.account_id = $1 AND a.owner_id = m.chat_id) AS has_avatar, "
|
||||
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, false) "
|
||||
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS is_bot, "
|
||||
"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 = m.chat_id) AS is_contact, "
|
||||
"(SELECT ch.raw->'chat'->>'type' = 'ChatType.CHANNEL' FROM chat_history ch "
|
||||
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
|
||||
"AND ch.raw->'chat'->>'type' IS NOT NULL "
|
||||
"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 = m.chat_id "
|
||||
"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 = m.chat_id "
|
||||
"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 messages m WHERE m.account_id = $1 "
|
||||
"GROUP BY m.chat_id ORDER BY last_date DESC LIMIT $2 OFFSET $3",
|
||||
"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,
|
||||
page.capped_limit,
|
||||
page.offset,
|
||||
@@ -146,9 +156,43 @@ async def get_chat_history(
|
||||
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,
|
||||
chat_id,
|
||||
message_id,
|
||||
)
|
||||
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],
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import asyncpg
|
||||
|
||||
from utils.read.message_view import load_raw
|
||||
from utils.read.models import MediaVersionView, MediaView
|
||||
|
||||
_MEDIA_COLS = (
|
||||
@@ -9,6 +10,45 @@ _MEDIA_COLS = (
|
||||
|
||||
_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:
|
||||
row = await pool.fetchrow(
|
||||
@@ -28,7 +68,9 @@ async def get_message_media(
|
||||
chat_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(
|
||||
|
||||
@@ -141,6 +141,13 @@ class ServiceView(BaseModel):
|
||||
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
|
||||
@@ -178,6 +185,7 @@ class MessageView(BaseModel):
|
||||
sticker: StickerView | None = None
|
||||
is_sticker: bool = False
|
||||
is_animated_emoji: bool = False
|
||||
read: bool = False
|
||||
|
||||
|
||||
class MessageVersionView(BaseModel):
|
||||
@@ -217,6 +225,13 @@ class AvatarRef(BaseModel):
|
||||
mime: str | None
|
||||
|
||||
|
||||
class CustomEmojiRef(BaseModel):
|
||||
storage_key: str | None
|
||||
downloaded: bool
|
||||
mime: str | None
|
||||
kind: str | None
|
||||
|
||||
|
||||
class CallbackView(BaseModel):
|
||||
position: int
|
||||
label: str | None
|
||||
@@ -256,6 +271,20 @@ class PresenceHourly(BaseModel):
|
||||
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):
|
||||
peer_id: int
|
||||
first_name: str | 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]
|
||||
|
||||
|
||||
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(
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>.
|
||||
@@ -6,6 +6,8 @@
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.18.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"pako": "^2.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
@@ -16,6 +18,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pako": "^2.0.4",
|
||||
"sass": "^1.100.0",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
@@ -193,6 +196,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
@@ -293,6 +298,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
@@ -315,6 +322,8 @@
|
||||
|
||||
"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-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pako": "^2.0.4",
|
||||
"sass": "^1.100.0",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
@@ -31,6 +32,8 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.18.1"
|
||||
"bits-ui": "^2.18.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { request } from "$lib/api/client";
|
||||
import type {
|
||||
Account,
|
||||
CaptureToggles,
|
||||
Chat,
|
||||
Folder,
|
||||
JobStatus,
|
||||
JobView,
|
||||
MediaVersion,
|
||||
MediaView,
|
||||
MessageVersion,
|
||||
MessageView,
|
||||
PeerView,
|
||||
PinnedView,
|
||||
PolicyChatKind,
|
||||
PolicyCreate,
|
||||
PolicyRecord,
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
ResponseStats,
|
||||
SearchHit,
|
||||
VolumeBucket,
|
||||
} from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
@@ -29,6 +40,43 @@ 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 } = {}
|
||||
@@ -39,6 +87,55 @@ export function listMessages(
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -83,6 +180,30 @@ 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
|
||||
@@ -105,6 +226,16 @@ export function getMessageMedia(
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -139,6 +139,13 @@ export interface ServiceView {
|
||||
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;
|
||||
@@ -169,6 +176,7 @@ export interface MessageView {
|
||||
poll: PollView | null;
|
||||
quote: string | null;
|
||||
reactions: ReactionCount[];
|
||||
read: boolean;
|
||||
reply: ReplyView | null;
|
||||
sender_id: number | null;
|
||||
service: ServiceView | null;
|
||||
@@ -257,6 +265,20 @@ export interface PresenceHourly {
|
||||
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;
|
||||
@@ -323,6 +345,13 @@ export interface PolicyRecord extends CaptureToggles {
|
||||
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;
|
||||
@@ -373,3 +402,32 @@ export interface JobView {
|
||||
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,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>
|
||||
@@ -1,10 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { getPeer } from "$lib/api/endpoints";
|
||||
import type { PeerView } from "$lib/api/types";
|
||||
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 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;
|
||||
@@ -15,6 +25,23 @@
|
||||
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) {
|
||||
@@ -39,10 +66,48 @@
|
||||
};
|
||||
});
|
||||
|
||||
const fallbackTitle = $derived(chat?.title ?? `Chat ${chatId}`);
|
||||
$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}`;
|
||||
}
|
||||
@@ -66,7 +131,39 @@
|
||||
/>
|
||||
<div class="info">
|
||||
<h2 class="title">{title}</h2>
|
||||
<span class="subtitle">{subtitle}</span>
|
||||
<span class="subtitle" class:online={isDm && presence?.status === "online"}>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -88,6 +185,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
@@ -100,5 +204,9 @@
|
||||
.subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.online {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
: chats.list.filter((chat) => folderContains(selectedFolder, chat))
|
||||
);
|
||||
|
||||
const SCROLL_THRESHOLD = 600;
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null) {
|
||||
return;
|
||||
@@ -32,9 +34,16 @@
|
||||
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">
|
||||
<div class="chat-list custom-scroll" onscroll={onScroll}>
|
||||
{#if chats.loading && chats.list.length === 0}
|
||||
{#each skeletonRows as index (index)}
|
||||
<div class="row-skeleton">
|
||||
@@ -53,7 +62,7 @@
|
||||
class="folder-view"
|
||||
in:fly={{ x: folders.direction * 24, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
{#if visibleChats.length === 0}
|
||||
{#if visibleChats.length === 0 && !chats.hasMore}
|
||||
<EmptyState
|
||||
title="Empty folder"
|
||||
description="No chats match this folder yet"
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
let { chat, selected, onclick }: Props = $props();
|
||||
|
||||
const title = $derived(chat.title ?? `Chat ${chat.chat_id}`);
|
||||
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(
|
||||
|
||||
@@ -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,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>
|
||||
@@ -1,13 +1,19 @@
|
||||
<script lang="ts">
|
||||
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 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";
|
||||
@@ -41,6 +47,11 @@
|
||||
|
||||
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(
|
||||
@@ -107,14 +118,25 @@
|
||||
deleted
|
||||
</span>
|
||||
{/if}
|
||||
{#if message.media.length > 1}
|
||||
<MediaAlbum
|
||||
media={message.media}
|
||||
chatId={message.chat_id}
|
||||
onopen={onmedia}
|
||||
/>
|
||||
{:else if message.has_media}
|
||||
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
|
||||
{#if 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">
|
||||
@@ -124,10 +146,19 @@
|
||||
{own}
|
||||
/>
|
||||
</div>
|
||||
{:else if !(message.has_media || deleted)}
|
||||
{:else if !(message.has_media || deleted || special)}
|
||||
<div class="text empty">(no text)</div>
|
||||
{/if}
|
||||
<MessageMeta {message} {onversions} />
|
||||
{#if message.web_page}
|
||||
<WebPage {message} {own} {onmedia} />
|
||||
{/if}
|
||||
{#if message.reactions.length}
|
||||
<Reactions reactions={message.reactions} {own} />
|
||||
{/if}
|
||||
<MessageMeta {message} {own} {onversions} />
|
||||
{#if message.inline_buttons.length}
|
||||
<InlineButtons rows={message.inline_buttons} />
|
||||
{/if}
|
||||
{#if lastInGroup}
|
||||
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
|
||||
<path
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
import { tick } from "svelte";
|
||||
import { listMessages } from "$lib/api/endpoints";
|
||||
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import 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 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;
|
||||
@@ -26,6 +30,7 @@
|
||||
const SCROLL_THRESHOLD = 160;
|
||||
const STICK_OFFSET = 9;
|
||||
const IDLE_DELAY = 1500;
|
||||
const JUMP_MAX_PAGES = 12;
|
||||
|
||||
let messages = $state<MessageView[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -96,6 +101,11 @@
|
||||
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);
|
||||
@@ -217,6 +227,139 @@
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
let guard = 0;
|
||||
while (
|
||||
!messages.some((m) => m.message_id === messageId) &&
|
||||
hasMore &&
|
||||
(messages.length === 0 || messages[0].message_id > messageId) &&
|
||||
guard < JUMP_MAX_PAGES
|
||||
) {
|
||||
await loadOlder();
|
||||
guard++;
|
||||
}
|
||||
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,
|
||||
@@ -229,52 +372,59 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="message-list custom-scroll"
|
||||
onscroll={onScroll}
|
||||
>
|
||||
{#if loading && messages.length === 0}
|
||||
<div class="messages-container">
|
||||
{#each skeletonBubbles as width, index (index)}
|
||||
<div class="bubble-skeleton skeleton" style:width="{width}%"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState
|
||||
title="No messages"
|
||||
description="This chat has no archived messages"
|
||||
/>
|
||||
{:else}
|
||||
<div class="messages-container">
|
||||
{#if loadingOlder}
|
||||
<div class="loading-older"><Spinner /></div>
|
||||
{/if}
|
||||
{#each rows as row (row.message.message_id)}
|
||||
{#if row.daySeparator}
|
||||
<div
|
||||
class="day-separator"
|
||||
class:idle={!scrolling && row.dayKey === stuckDay}
|
||||
data-day={row.dayKey}
|
||||
>
|
||||
<span>{row.daySeparator}</span>
|
||||
</div>
|
||||
<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}
|
||||
<MessageBubble
|
||||
message={row.message}
|
||||
own={row.own}
|
||||
{isGroupChat}
|
||||
firstInGroup={row.firstInGroup}
|
||||
lastInGroup={row.lastInGroup}
|
||||
highlighted={highlightId === row.message.message_id}
|
||||
animate={!suppressAppear}
|
||||
onjump={jumpToMessage}
|
||||
onmedia={(index) => openMedia(row.message, index)}
|
||||
onversions={() => openVersions(row.message.message_id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaViewer
|
||||
@@ -290,9 +440,16 @@
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.message-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
} 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 Icon from "$lib/components/ui/Icon.svelte";
|
||||
@@ -123,9 +124,7 @@
|
||||
playsinline
|
||||
></video>
|
||||
{:else if ready && isTgsSticker}
|
||||
<div class="media-sticker tgs">
|
||||
<span class="tgs-emoji">{message.sticker?.emoji ?? "🎞"}</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -215,23 +214,6 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tgs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.tgs-emoji {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gif-badge {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<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 }: Props = $props();
|
||||
let { message, onversions, own = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="MessageMeta">
|
||||
@@ -22,6 +24,13 @@
|
||||
</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">
|
||||
|
||||
@@ -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,68 @@
|
||||
<script lang="ts">
|
||||
import type { ReactionCount } from "$lib/api/types";
|
||||
import CustomEmoji from "$lib/components/CustomEmoji.svelte";
|
||||
|
||||
interface Props {
|
||||
own: boolean;
|
||||
reactions: ReactionCount[];
|
||||
}
|
||||
|
||||
let { reactions, own }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="Reactions" class:own>
|
||||
{#each reactions as reaction, index (reaction.custom_emoji_id ?? reaction.emoji ?? index)}
|
||||
<span class="reaction" class:chosen={reaction.chosen}>
|
||||
{#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>
|
||||
</span>
|
||||
{/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-radius: 1rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text);
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import JobsPanel from "$lib/components/jobs/JobsPanel.svelte";
|
||||
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
|
||||
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
|
||||
import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { type RightPanel, ui } from "$lib/stores/ui.svelte";
|
||||
|
||||
const titles: Record<RightPanel, string> = {
|
||||
profile: "Профиль",
|
||||
search: "Поиск",
|
||||
versions: "Версии",
|
||||
reactions: "Реакции",
|
||||
links: "Ссылки",
|
||||
annotations: "Заметки",
|
||||
jobs: "Данные и хранилище",
|
||||
presence: "Аналитика",
|
||||
stories: "Сторис",
|
||||
policy: "Политика захвата",
|
||||
};
|
||||
</script>
|
||||
|
||||
<header class="right-header">
|
||||
<Button
|
||||
variant="translucent"
|
||||
round
|
||||
smaller
|
||||
onclick={() => ui.closePanel()}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
|
||||
</header>
|
||||
|
||||
<div class="right-body custom-scroll">
|
||||
{#if ui.rightPanel === "policy"}
|
||||
<PolicyEditor />
|
||||
{:else if ui.rightPanel === "jobs"}
|
||||
<JobsPanel />
|
||||
{:else if ui.rightPanel === "presence"}
|
||||
<AnalyticsPanel />
|
||||
{:else if ui.rightPanel === "search"}
|
||||
<ChatSearchPanel />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.right-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
height: var(--header-height);
|
||||
padding: 0 0.625rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.right-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import MessageMedia from "$lib/components/MessageMedia.svelte";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
onmedia: (index: number) => void;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
let { message, own, onmedia }: Props = $props();
|
||||
|
||||
const web = $derived(message.web_page);
|
||||
</script>
|
||||
|
||||
{#if web}
|
||||
<div class="WebPage">
|
||||
{#if web.has_photo}
|
||||
<div class="photo">
|
||||
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
|
||||
</div>
|
||||
{/if}
|
||||
<a class="card" href={web.url} target="_blank" rel="noopener">
|
||||
{#if web.site_name}
|
||||
<div class="site">{web.site_name}</div>
|
||||
{:else if web.display_url}
|
||||
<div class="site">{web.display_url}</div>
|
||||
{/if}
|
||||
{#if web.title}
|
||||
<div class="title">{web.title}</div>
|
||||
{/if}
|
||||
{#if web.description}
|
||||
<div class="desc">{web.description}</div>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.WebPage {
|
||||
margin-top: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.photo {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { listJobs } from "$lib/api/endpoints";
|
||||
import type { JobStatus, JobView } from "$lib/api/types";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatFull } from "$lib/format/datetime";
|
||||
|
||||
interface Props {
|
||||
version?: number;
|
||||
}
|
||||
|
||||
let { version = 0 }: Props = $props();
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
backfill: "Бэкфилл",
|
||||
fetch_media: "Докачка медиа",
|
||||
fetch_avatar: "Аватар",
|
||||
fetch_custom_emoji: "Кастом-эмодзи",
|
||||
enrich_chat: "Обогащение чата",
|
||||
transcribe: "Расшифровка",
|
||||
sync_dialogs: "Синхронизация диалогов",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<JobStatus, string> = {
|
||||
pending: "В очереди",
|
||||
running: "Выполняется",
|
||||
done: "Готово",
|
||||
failed: "Ошибка",
|
||||
canceled: "Отменено",
|
||||
paused: "Пауза",
|
||||
};
|
||||
|
||||
let jobs = $state<JobView[]>([]);
|
||||
let loading = $state(true);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function isActive(list: JobView[]): boolean {
|
||||
return list.some((j) => j.status === "pending" || j.status === "running");
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (isActive(jobs)) {
|
||||
timer = setTimeout(() => {
|
||||
load().catch(() => undefined);
|
||||
}, POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
jobs = await listJobs();
|
||||
loading = false;
|
||||
schedule();
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return KIND_LABELS[kind] ?? kind;
|
||||
}
|
||||
|
||||
function processed(job: JobView): number | null {
|
||||
const value = job.progress.processed;
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
function chatId(job: JobView): number | null {
|
||||
const value = job.params.chat_id;
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (version >= 0) {
|
||||
load().catch(() => {
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else if jobs.length === 0}
|
||||
<div class="state empty">Задач нет</div>
|
||||
{:else}
|
||||
<div class="jobs">
|
||||
{#each jobs as job (job.id)}
|
||||
<div class="job">
|
||||
<div class="job-head">
|
||||
<span class="kind">{kindLabel(job.kind)}</span>
|
||||
<span class="badge {job.status}">{STATUS_LABELS[job.status]}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{#if chatId(job) !== null}
|
||||
<span>чат {chatId(job)}</span>
|
||||
{/if}
|
||||
{#if processed(job) !== null}
|
||||
<span>обработано {processed(job)}</span>
|
||||
{/if}
|
||||
{#if job.flood_waits > 0}
|
||||
<span>flood-wait {job.flood_waits}</span>
|
||||
{/if}
|
||||
<span class="time">{formatFull(job.created_at)}</span>
|
||||
</div>
|
||||
{#if job.error}
|
||||
<div class="error">{job.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
&.empty {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.jobs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.job {
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.job-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.kind {
|
||||
flex: 1;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-white);
|
||||
background-color: var(--color-text-secondary);
|
||||
|
||||
&.pending {
|
||||
background-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&.running {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.done {
|
||||
background-color: var(--color-green);
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
overflow: hidden;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { enqueueBackfill, syncDialogs } from "$lib/api/endpoints";
|
||||
import JobList from "$lib/components/jobs/JobList.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
let version = $state(0);
|
||||
let selected = $state<number | null>(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
let media = $state(true);
|
||||
let picking = $state(false);
|
||||
let filter = $state("");
|
||||
let starting = $state(false);
|
||||
let syncing = $state(false);
|
||||
|
||||
const availableChats = $derived(
|
||||
chats.list
|
||||
.filter((c) =>
|
||||
(c.title ?? "").toLowerCase().includes(filter.trim().toLowerCase())
|
||||
)
|
||||
.slice(0, 40)
|
||||
);
|
||||
|
||||
function chatTitle(id: number | null): string {
|
||||
if (id === null) {
|
||||
return "Чат не выбран";
|
||||
}
|
||||
return chats.byId(id)?.title ?? `Чат ${id}`;
|
||||
}
|
||||
|
||||
function selectChat(id: number) {
|
||||
selected = id;
|
||||
picking = false;
|
||||
filter = "";
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (selected === null || starting) {
|
||||
return;
|
||||
}
|
||||
starting = true;
|
||||
try {
|
||||
await enqueueBackfill(selected, media);
|
||||
toasts.success("Бэкфилл запущен");
|
||||
version += 1;
|
||||
} catch {
|
||||
toasts.error("Не удалось запустить бэкфилл");
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
if (syncing) {
|
||||
return;
|
||||
}
|
||||
syncing = true;
|
||||
try {
|
||||
await syncDialogs();
|
||||
toasts.success("Синхронизация диалогов запущена");
|
||||
version += 1;
|
||||
} catch {
|
||||
toasts.error("Не удалось синхронизировать диалоги");
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
chats.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<section>
|
||||
<div class="section-title">Диалоги</div>
|
||||
<p class="hint">
|
||||
Синхронизация подтягивает все диалоги аккаунта — старые чаты станут видны
|
||||
и доступны для бэкфилла.
|
||||
</p>
|
||||
<div class="action">
|
||||
<Button variant="secondary" fluid loading={syncing} onclick={sync}>
|
||||
<Icon name="reload" />
|
||||
<span>Синхронизировать диалоги</span>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Бэкфилл</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="select"
|
||||
onclick={() => {
|
||||
picking = !picking;
|
||||
}}
|
||||
>
|
||||
<Icon name="comments" size="1.25rem" />
|
||||
<span class="select-label">{chatTitle(selected)}</span>
|
||||
<Icon name="down" size="1.25rem" />
|
||||
</button>
|
||||
|
||||
{#if picking}
|
||||
<div class="picker">
|
||||
<input
|
||||
class="filter"
|
||||
type="text"
|
||||
placeholder="Поиск чата"
|
||||
bind:value={filter}
|
||||
>
|
||||
{#each availableChats as chat (chat.chat_id)}
|
||||
<button
|
||||
type="button"
|
||||
class="pick-row"
|
||||
onclick={() => selectChat(chat.chat_id)}
|
||||
>
|
||||
{chat.title ?? `Чат ${chat.chat_id}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-row"
|
||||
role="switch"
|
||||
aria-checked={media}
|
||||
onclick={() => {
|
||||
media = !media;
|
||||
}}
|
||||
>
|
||||
<span class="label">Скачивать медиа</span>
|
||||
<span class="switch" class:on={media}>
|
||||
<span class="knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="action">
|
||||
<Button
|
||||
variant="primary"
|
||||
fluid
|
||||
disabled={selected === null}
|
||||
loading={starting}
|
||||
onclick={start}
|
||||
>
|
||||
<Icon name="cloud-download" />
|
||||
<span>Запустить бэкфилл</span>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Задачи</div>
|
||||
<JobList {version} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.select {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.select-label {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.filter {
|
||||
margin: 0.25rem 0.5rem 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pick-row {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.switch {
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 1.375rem;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
background-color: var(--color-borders);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&.on {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-white);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
.on & {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0 1rem 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let { url, size = 12 }: Props = $props();
|
||||
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const target = container;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let anim: { destroy: () => void } | null = null;
|
||||
(async () => {
|
||||
try {
|
||||
const [lottieModule, pako] = await Promise.all([
|
||||
import("lottie-web"),
|
||||
import("pako"),
|
||||
]);
|
||||
const response = await fetch(url);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const json = JSON.parse(pako.inflate(bytes, { to: "string" }));
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
anim = lottieModule.default.loadAnimation({
|
||||
container: target,
|
||||
renderer: "svg",
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: json,
|
||||
});
|
||||
} catch {
|
||||
// tgs decode/render failed — leave empty
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
anim?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="TgsSticker"
|
||||
bind:this={container}
|
||||
style="--tgs-size: {size}rem;"
|
||||
></div>
|
||||
|
||||
<style lang="scss">
|
||||
.TgsSticker {
|
||||
width: var(--tgs-size);
|
||||
height: var(--tgs-size);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import type { CaptureToggles } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
onchange: (key: keyof CaptureToggles, value: boolean) => void;
|
||||
toggles: CaptureToggles;
|
||||
}
|
||||
|
||||
let { toggles, disabled = false, onchange }: Props = $props();
|
||||
|
||||
const rows: { key: keyof CaptureToggles; label: string }[] = [
|
||||
{ key: "messages", label: "Сообщения" },
|
||||
{ key: "media", label: "Медиа" },
|
||||
{ key: "self_destruct_media", label: "Самоуничтожающиеся" },
|
||||
{ key: "stt", label: "Расшифровка голоса" },
|
||||
{ key: "reactions", label: "Реакции" },
|
||||
{ key: "track_edits_deletes", label: "Правки и удаления" },
|
||||
{ key: "profile_history", label: "История профиля" },
|
||||
{ key: "stories", label: "Сторис" },
|
||||
{ key: "presence", label: "Presence" },
|
||||
{ key: "backfill", label: "Бэкфилл" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="toggles">
|
||||
{#each rows as row (row.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-row"
|
||||
role="switch"
|
||||
aria-checked={toggles[row.key]}
|
||||
{disabled}
|
||||
onclick={() => onchange(row.key, !toggles[row.key])}
|
||||
>
|
||||
<span class="label">{row.label}</span>
|
||||
<span class="switch" class:on={toggles[row.key]}>
|
||||
<span class="knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.toggle-row {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.switch {
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 1.375rem;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
background-color: var(--color-borders);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&.on {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-white);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
.on & {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,381 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
createPolicy,
|
||||
deletePolicy,
|
||||
listFolders,
|
||||
listPolicies,
|
||||
updatePolicy,
|
||||
} from "$lib/api/endpoints";
|
||||
import type {
|
||||
CaptureToggles,
|
||||
Folder,
|
||||
PolicyRecord,
|
||||
PolicyScopeType,
|
||||
} from "$lib/api/types";
|
||||
import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
let policies = $state<PolicyRecord[]>([]);
|
||||
let folders = $state<Folder[]>([]);
|
||||
let loading = $state(true);
|
||||
let pickFolder = $state(false);
|
||||
let pickChat = $state(false);
|
||||
let chatFilter = $state("");
|
||||
|
||||
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
|
||||
{ scope: "default_dm", label: "Личные чаты" },
|
||||
{ scope: "default_group", label: "Группы" },
|
||||
{ scope: "default_channel", label: "Каналы" },
|
||||
];
|
||||
|
||||
const ALL_FALSE: CaptureToggles = {
|
||||
messages: false,
|
||||
media: false,
|
||||
self_destruct_media: false,
|
||||
stt: false,
|
||||
reactions: false,
|
||||
track_edits_deletes: false,
|
||||
profile_history: false,
|
||||
stories: false,
|
||||
presence: false,
|
||||
backfill: false,
|
||||
};
|
||||
|
||||
function pickToggles(t: CaptureToggles): CaptureToggles {
|
||||
return {
|
||||
messages: t.messages,
|
||||
media: t.media,
|
||||
self_destruct_media: t.self_destruct_media,
|
||||
stt: t.stt,
|
||||
reactions: t.reactions,
|
||||
track_edits_deletes: t.track_edits_deletes,
|
||||
profile_history: t.profile_history,
|
||||
stories: t.stories,
|
||||
presence: t.presence,
|
||||
backfill: t.backfill,
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = $derived(
|
||||
DEFAULT_SCOPES.map((d) => ({
|
||||
label: d.label,
|
||||
record: policies.find((p) => p.scope_type === d.scope),
|
||||
})).filter((d): d is { label: string; record: PolicyRecord } =>
|
||||
Boolean(d.record)
|
||||
)
|
||||
);
|
||||
const folderPolicies = $derived(
|
||||
policies.filter((p) => p.scope_type === "folder")
|
||||
);
|
||||
const chatPolicies = $derived(
|
||||
policies.filter((p) => p.scope_type === "chat")
|
||||
);
|
||||
const availableFolders = $derived(
|
||||
folders.filter(
|
||||
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
|
||||
)
|
||||
);
|
||||
const availableChats = $derived(
|
||||
chats.list
|
||||
.filter((c) => !chatPolicies.some((p) => p.scope_id === c.chat_id))
|
||||
.filter((c) =>
|
||||
(c.title ?? "").toLowerCase().includes(chatFilter.trim().toLowerCase())
|
||||
)
|
||||
.slice(0, 40)
|
||||
);
|
||||
|
||||
function folderTitle(id: number | null): string {
|
||||
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
|
||||
}
|
||||
|
||||
function chatTitle(id: number | null): string {
|
||||
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
|
||||
}
|
||||
|
||||
async function toggle(
|
||||
record: PolicyRecord,
|
||||
key: keyof CaptureToggles,
|
||||
value: boolean
|
||||
) {
|
||||
const next = { ...pickToggles(record), [key]: value };
|
||||
policies = policies.map((p) =>
|
||||
p.id === record.id ? { ...p, [key]: value } : p
|
||||
);
|
||||
try {
|
||||
await updatePolicy(record.id, next);
|
||||
} catch {
|
||||
toasts.error("Не удалось сохранить политику");
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function addOverride(scope: "folder" | "chat", scopeId: number) {
|
||||
try {
|
||||
const record = await createPolicy({
|
||||
...ALL_FALSE,
|
||||
scope_type: scope,
|
||||
scope_id: scopeId,
|
||||
});
|
||||
policies = [...policies, record];
|
||||
pickFolder = false;
|
||||
pickChat = false;
|
||||
chatFilter = "";
|
||||
} catch {
|
||||
toasts.error("Не удалось создать оверрайд");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOverride(record: PolicyRecord) {
|
||||
policies = policies.filter((p) => p.id !== record.id);
|
||||
try {
|
||||
await deletePolicy(record.id);
|
||||
} catch {
|
||||
toasts.error("Не удалось удалить оверрайд");
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
chats.load();
|
||||
try {
|
||||
await reload();
|
||||
} catch {
|
||||
toasts.error("Не удалось загрузить политики");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet card(record: PolicyRecord, title: string, onremove?: () => void)}
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">{title}</span>
|
||||
{#if onremove}
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
aria-label="Удалить оверрайд"
|
||||
onclick={onremove}
|
||||
>
|
||||
<Icon name="close" size="1.125rem" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<CaptureToggleList
|
||||
toggles={record}
|
||||
onchange={(key, value) => toggle(record, key, value)}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else}
|
||||
<div class="editor">
|
||||
<section>
|
||||
<div class="section-title">По умолчанию</div>
|
||||
{#each defaults as item (item.record.id)}
|
||||
{@render card(item.record, item.label)}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Оверрайды папок</div>
|
||||
{#each folderPolicies as record (record.id)}
|
||||
{@render card(record, folderTitle(record.scope_id), () =>
|
||||
removeOverride(record)
|
||||
)}
|
||||
{/each}
|
||||
{#if availableFolders.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
onclick={() => {
|
||||
pickFolder = !pickFolder;
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>Добавить папку</span>
|
||||
</button>
|
||||
{#if pickFolder}
|
||||
<div class="picker">
|
||||
{#each availableFolders as folder (folder.folder_id)}
|
||||
<button
|
||||
type="button"
|
||||
class="pick-row"
|
||||
onclick={() => addOverride("folder", folder.folder_id)}
|
||||
>
|
||||
{folder.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Оверрайды чатов</div>
|
||||
{#each chatPolicies as record (record.id)}
|
||||
{@render card(record, chatTitle(record.scope_id), () =>
|
||||
removeOverride(record)
|
||||
)}
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
onclick={() => {
|
||||
pickChat = !pickChat;
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>Добавить чат</span>
|
||||
</button>
|
||||
{#if pickChat}
|
||||
<div class="picker">
|
||||
<input
|
||||
class="filter"
|
||||
type="text"
|
||||
placeholder="Поиск чата"
|
||||
bind:value={chatFilter}
|
||||
>
|
||||
{#each availableChats as chat (chat.chat_id)}
|
||||
<button
|
||||
type="button"
|
||||
class="pick-row"
|
||||
onclick={() => addOverride("chat", chat.chat_id)}
|
||||
>
|
||||
{chat.title ?? `Чат ${chat.chat_id}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.editor {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.remove {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.filter {
|
||||
margin: 0.25rem 0.5rem 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pick-row {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
getCurrentPresence,
|
||||
getMessageVolume,
|
||||
getPresenceHistory,
|
||||
getPresenceHourly,
|
||||
getResponseStats,
|
||||
} from "$lib/api/endpoints";
|
||||
import type {
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
ResponseStats,
|
||||
VolumeBucket,
|
||||
} from "$lib/api/types";
|
||||
import PresenceHeatmap from "$lib/components/presence/PresenceHeatmap.svelte";
|
||||
import VolumeChart from "$lib/components/presence/VolumeChart.svelte";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import { formatFull } from "$lib/format/datetime";
|
||||
import { formatPresence } from "$lib/format/presence";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { events } from "$lib/stores/events.svelte";
|
||||
|
||||
const HISTORY_LIMIT = 200;
|
||||
const SECONDS_IN_MINUTE = 60;
|
||||
const SECONDS_IN_HOUR = 3600;
|
||||
const SECONDS_IN_DAY = 86_400;
|
||||
|
||||
const chatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
const isDm = $derived(chatId !== null && chatId > 0);
|
||||
|
||||
let current = $state<PresenceSample | null>(null);
|
||||
let hourly = $state<PresenceHourly[]>([]);
|
||||
let history = $state<PresenceSample[]>([]);
|
||||
let volume = $state<VolumeBucket[]>([]);
|
||||
let response = $state<ResponseStats | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
const changes = $derived.by(() => {
|
||||
const result: PresenceSample[] = [];
|
||||
let last: string | null = null;
|
||||
for (const sample of history) {
|
||||
if (sample.status !== last) {
|
||||
result.push(sample);
|
||||
last = sample.status;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds === null) {
|
||||
return "—";
|
||||
}
|
||||
if (seconds < SECONDS_IN_MINUTE) {
|
||||
return `${Math.round(seconds)} с`;
|
||||
}
|
||||
if (seconds < SECONDS_IN_HOUR) {
|
||||
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`;
|
||||
}
|
||||
if (seconds < SECONDS_IN_DAY) {
|
||||
return `${Math.round(seconds / SECONDS_IN_HOUR)} ч`;
|
||||
}
|
||||
return `${Math.round(seconds / SECONDS_IN_DAY)} д`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null || chatId === null) {
|
||||
return;
|
||||
}
|
||||
const id = chatId;
|
||||
let active = true;
|
||||
loading = true;
|
||||
current = null;
|
||||
hourly = [];
|
||||
history = [];
|
||||
volume = [];
|
||||
response = null;
|
||||
Promise.all([getMessageVolume(id), getResponseStats(id)])
|
||||
.then(([vol, resp]) => {
|
||||
if (active) {
|
||||
volume = vol;
|
||||
response = resp;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
if (id > 0) {
|
||||
Promise.all([
|
||||
getCurrentPresence(id),
|
||||
getPresenceHourly(id),
|
||||
getPresenceHistory(id, { limit: HISTORY_LIMIT }),
|
||||
])
|
||||
.then(([cur, hrs, hist]) => {
|
||||
if (active) {
|
||||
current = cur;
|
||||
hourly = hrs;
|
||||
history = hist;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const unsub = events.subscribe((event) => {
|
||||
if (event.type === "presence" && event.peer_id === id && event.sample) {
|
||||
current = event.sample;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
unsub();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if chatId === null}
|
||||
<EmptyState title="Аналитика" description="Откройте чат" />
|
||||
{:else}
|
||||
<div class="analytics">
|
||||
<section>
|
||||
<h3>Объём сообщений (90 дней)</h3>
|
||||
{#if volume.length === 0}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
|
||||
{:else}
|
||||
<VolumeChart {volume} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Время ответа</h3>
|
||||
{#if response}
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="value"
|
||||
>{formatDuration(response.mine_median_seconds)}</span
|
||||
>
|
||||
<span class="caption">я отвечаю ({response.mine_count})</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="value"
|
||||
>{formatDuration(response.their_median_seconds)}</span
|
||||
>
|
||||
<span class="caption">мне отвечают ({response.their_count})</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if isDm}
|
||||
<section>
|
||||
<div class="status" class:online={current?.status === "online"}>
|
||||
{current ? formatPresence(current) : "нет данных"}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Когда в сети</h3>
|
||||
{#if hourly.length === 0}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
|
||||
{:else}
|
||||
<PresenceHeatmap {hourly} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>История статусов</h3>
|
||||
{#if changes.length === 0}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Нет записей"}</p>
|
||||
{:else}
|
||||
<ul class="changes">
|
||||
{#each changes as sample (sample.ts)}
|
||||
<li>
|
||||
<span
|
||||
class="dot"
|
||||
class:online={sample.status === "online"}
|
||||
></span>
|
||||
<span class="label">{formatPresence(sample)}</span>
|
||||
<span class="time">{formatFull(sample.ts)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.analytics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.online {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.changes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changes li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-text-secondary);
|
||||
|
||||
&.online {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import type { PresenceHourly } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
hourly: PresenceHourly[];
|
||||
}
|
||||
|
||||
const { hourly }: Props = $props();
|
||||
|
||||
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
||||
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
|
||||
const HOUR_TICKS = new Set([0, 6, 12, 18]);
|
||||
|
||||
interface Cell {
|
||||
online: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const grid = $derived.by(() => {
|
||||
const cells: Cell[][] = DAYS.map(() =>
|
||||
HOURS.map(() => ({ online: 0, total: 0 }))
|
||||
);
|
||||
for (const bucket of hourly) {
|
||||
const date = new Date(bucket.bucket);
|
||||
const day = (date.getDay() + 6) % 7;
|
||||
const cell = cells[day]?.[date.getHours()];
|
||||
if (cell) {
|
||||
cell.online += bucket.online_samples;
|
||||
cell.total += bucket.samples;
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
function intensity(cell: Cell): number {
|
||||
return cell.total > 0 ? cell.online / cell.total : 0;
|
||||
}
|
||||
|
||||
function cellTitle(day: number, hour: number, cell: Cell): string {
|
||||
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0;
|
||||
return `${DAYS[day]} ${hour}:00 — ${pct}% online`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="heatmap">
|
||||
{#each grid as row, day (day)}
|
||||
<div class="heat-row">
|
||||
<span class="day-label">{DAYS[day]}</span>
|
||||
<div class="cells">
|
||||
{#each row as cell, hour (hour)}
|
||||
<span
|
||||
class="cell"
|
||||
class:empty={cell.total === 0}
|
||||
style:--i={intensity(cell)}
|
||||
title={cellTitle(day, hour, cell)}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="heat-row axis">
|
||||
<span class="day-label"></span>
|
||||
<div class="cells">
|
||||
{#each HOURS as hour (hour)}
|
||||
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.heatmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.heat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
width: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cells {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
background-color: var(--color-primary);
|
||||
opacity: calc(0.12 + 0.88 * var(--i));
|
||||
|
||||
&.empty {
|
||||
background-color: var(--color-text-secondary);
|
||||
opacity: 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
.tick {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { VolumeBucket } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
volume: VolumeBucket[];
|
||||
}
|
||||
|
||||
const { volume }: Props = $props();
|
||||
|
||||
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
||||
|
||||
function barTitle(bucket: VolumeBucket): string {
|
||||
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="volume">
|
||||
<div class="bars">
|
||||
{#each volume as bucket (bucket.bucket)}
|
||||
<div
|
||||
class="bar"
|
||||
style:height={`${(bucket.total / max) * 100}%`}
|
||||
title={barTitle(bucket)}
|
||||
>
|
||||
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
|
||||
<div class="seg in" style:flex-grow={bucket.incoming}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="key"><span class="swatch out"></span>исходящие</span>
|
||||
<span class="key"><span class="swatch in"></span>входящие</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 7.5rem;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.seg.out {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.seg.in {
|
||||
background-color: var(--color-text-secondary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&.out {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.in {
|
||||
background-color: var(--color-text-secondary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { searchMessages } from "$lib/api/endpoints";
|
||||
import type { SearchHit } from "$lib/api/types";
|
||||
import SearchMessageItem from "$lib/components/search/SearchMessageItem.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 { ui } from "$lib/stores/ui.svelte";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
const chatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
let query = $state("");
|
||||
let hits = $state<SearchHit[]>([]);
|
||||
let loading = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
async function run(value: string, id: number) {
|
||||
const current = ++seq;
|
||||
loading = true;
|
||||
try {
|
||||
const result = await searchMessages(value, { chat_id: id });
|
||||
if (current === seq) {
|
||||
hits = result;
|
||||
}
|
||||
} catch {
|
||||
if (current === seq) {
|
||||
hits = [];
|
||||
}
|
||||
} finally {
|
||||
if (current === seq) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
query = (event.currentTarget as HTMLInputElement).value;
|
||||
const value = query.trim();
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (value.length === 0 || chatId === null) {
|
||||
seq++;
|
||||
hits = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
const id = chatId;
|
||||
loading = true;
|
||||
timer = setTimeout(() => {
|
||||
run(value, id).catch(() => undefined);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function openHit(messageId: number) {
|
||||
if (chatId !== null) {
|
||||
ui.requestJump(chatId, messageId);
|
||||
goto(`/app/${chatId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-search">
|
||||
<div class="search-input">
|
||||
<Icon name="search" size="1.25rem" class="chat-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск в чате"
|
||||
value={query}
|
||||
oninput={onInput}
|
||||
>
|
||||
</div>
|
||||
<div class="results custom-scroll">
|
||||
{#if loading && hits.length === 0}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{:else if hits.length > 0}
|
||||
{#each hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem {hit} onclick={() => openHit(hit.message_id)} />
|
||||
{/each}
|
||||
{:else if query.trim()}
|
||||
<EmptyState title="Ничего не найдено" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
height: 2.5rem;
|
||||
margin: 0.625rem;
|
||||
padding: 0 0.5rem 0 0.75rem;
|
||||
border-radius: 1.25rem;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
|
||||
&:focus-within {
|
||||
background-color: var(--color-background);
|
||||
box-shadow: inset 0 0 0 1.5px var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.chat-search .chat-search-icon) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4375rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { search } from "$lib/stores/search.svelte";
|
||||
|
||||
let input = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function onInput(event: Event) {
|
||||
search.setQuery((event.currentTarget as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
search.setQuery("");
|
||||
input?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search-input">
|
||||
<Icon name="search" size="1.25rem" class="search-icon" />
|
||||
<input
|
||||
bind:this={input}
|
||||
type="text"
|
||||
placeholder="Поиск"
|
||||
value={search.query}
|
||||
oninput={onInput}
|
||||
onfocus={() => search.open()}
|
||||
>
|
||||
{#if search.query}
|
||||
<button type="button" class="clear" onclick={clear} aria-label="Очистить">
|
||||
<Icon name="close" size="1.125rem" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.search-input {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
height: 2.5rem;
|
||||
padding: 0 0.5rem 0 0.75rem;
|
||||
border-radius: 1.25rem;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
background-color: var(--color-background);
|
||||
box-shadow: inset 0 0 0 1.5px var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.search-input .search-icon) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-icon-secondary);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
import type { SearchHit } from "$lib/api/types";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import { formatListDate } from "$lib/format/datetime";
|
||||
import { peerName } from "$lib/format/peer";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
interface Props {
|
||||
hit: SearchHit;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { hit, onclick }: Props = $props();
|
||||
|
||||
const chat = $derived(chats.byId(hit.chat_id));
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const ids: number[] = [];
|
||||
if (hit.chat_id > 0) {
|
||||
ids.push(hit.chat_id);
|
||||
}
|
||||
if (hit.sender_id !== null) {
|
||||
ids.push(hit.sender_id);
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
peers.ensure(ids);
|
||||
}
|
||||
});
|
||||
|
||||
const title = $derived.by(() => {
|
||||
if (chat?.title) {
|
||||
return chat.title;
|
||||
}
|
||||
if (hit.chat_id > 0) {
|
||||
return peerName(peers.get(hit.chat_id) ?? null) || `Chat ${hit.chat_id}`;
|
||||
}
|
||||
return `Chat ${hit.chat_id}`;
|
||||
});
|
||||
|
||||
const sender = $derived.by(() => {
|
||||
if (hit.sender_id === null) {
|
||||
return "";
|
||||
}
|
||||
if (hit.sender_id === ownId) {
|
||||
return "Вы";
|
||||
}
|
||||
return peerName(peers.get(hit.sender_id) ?? null);
|
||||
});
|
||||
|
||||
const snippet = $derived(hit.text ?? hit.extracted_text ?? "");
|
||||
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat");
|
||||
</script>
|
||||
|
||||
<button type="button" class="hit" use:ripple {onclick}>
|
||||
<Avatar
|
||||
name={title}
|
||||
colorKey={hit.chat_id}
|
||||
avatar={{ kind: avatarKind, id: hit.chat_id }}
|
||||
hasAvatar={chat?.has_avatar ?? false}
|
||||
size={3}
|
||||
/>
|
||||
<div class="body">
|
||||
<div class="top">
|
||||
<span class="title">{title}</span>
|
||||
<span class="date">{formatListDate(hit.date)}</span>
|
||||
</div>
|
||||
<div class="snippet">
|
||||
{#if sender}
|
||||
<span class="sender">{sender}: </span>
|
||||
{/if}
|
||||
{snippet}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.hit {
|
||||
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);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.1875rem;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.snippet {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.sender {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import ChatListItem from "$lib/components/ChatListItem.svelte";
|
||||
import SearchMessageItem from "$lib/components/search/SearchMessageItem.svelte";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { search } from "$lib/stores/search.svelte";
|
||||
import { ui } from "$lib/stores/ui.svelte";
|
||||
|
||||
const activeChatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
const hasChats = $derived(search.chatHits.length > 0);
|
||||
const hasMessages = $derived(search.messageHits.length > 0);
|
||||
const empty = $derived(!(search.loading || hasChats || hasMessages));
|
||||
|
||||
function openChat(chatId: number) {
|
||||
search.close();
|
||||
goto(`/app/${chatId}`);
|
||||
}
|
||||
|
||||
function openHit(chatId: number, messageId: number) {
|
||||
ui.requestJump(chatId, messageId);
|
||||
search.close();
|
||||
goto(`/app/${chatId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search-results custom-scroll">
|
||||
{#if hasChats}
|
||||
<div class="section-label">Чаты</div>
|
||||
{#each search.chatHits as chat (chat.chat_id)}
|
||||
<ChatListItem
|
||||
{chat}
|
||||
selected={chat.chat_id === activeChatId}
|
||||
onclick={() => openChat(chat.chat_id)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if search.loading && !hasMessages}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{:else if hasMessages}
|
||||
<div class="section-label">Сообщения</div>
|
||||
{#each search.messageHits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
onclick={() => openHit(hit.chat_id, hit.message_id)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if empty}
|
||||
<EmptyState title="Ничего не найдено" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.search-results {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4375rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
padding: 0.625rem 0.75rem 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
const appVersion = "0.0.1";
|
||||
</script>
|
||||
|
||||
<div class="about">
|
||||
<div class="title">Beavergram</div>
|
||||
<div class="line">Версия {appVersion}</div>
|
||||
<div class="line">Лицензия GPL-3.0</div>
|
||||
<a
|
||||
class="line link"
|
||||
href="https://github.com/Ajaxy/telegram-tt"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
UI портирован из Telegram A
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.about {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
|
||||
padding: 1.5rem 1rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
import { theme } from "$lib/stores/theme.svelte";
|
||||
|
||||
const options = [
|
||||
{ value: "light", label: "Светлая" },
|
||||
{ value: "dark", label: "Тёмная" },
|
||||
{ value: "system", label: "Системная" },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="appearance">
|
||||
{#each options as option (option.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="theme-row"
|
||||
use:ripple
|
||||
onclick={() => theme.set(option.value)}
|
||||
>
|
||||
<span class="dot" class:on={theme.preference === option.value}></span>
|
||||
<span class="label">{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.theme-row {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.25rem;
|
||||
padding: 0 1.25rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 1rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid var(--color-borders);
|
||||
border-radius: 50%;
|
||||
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&.on {
|
||||
border-width: 0.4375rem;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
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);
|
||||
const others = $derived(
|
||||
accounts.list.filter(
|
||||
(account) => account.account_id !== accounts.selectedId
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="my-account">
|
||||
{#if current}
|
||||
<div class="profile">
|
||||
<Avatar
|
||||
name={accountName(current)}
|
||||
colorKey={current.account_id}
|
||||
size={5}
|
||||
/>
|
||||
<div class="name">{accountName(current)}</div>
|
||||
{#if current.phone}
|
||||
<div class="phone">+{current.phone}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if others.length > 0}
|
||||
<div class="switch">
|
||||
{#each others as account (account.account_id)}
|
||||
<button
|
||||
type="button"
|
||||
class="switch-row"
|
||||
use:ripple
|
||||
onclick={() => accounts.select(account.account_id)}
|
||||
>
|
||||
<Avatar
|
||||
name={accountName(account)}
|
||||
colorKey={account.account_id}
|
||||
size={2.25}
|
||||
/>
|
||||
<span>{accountName(account)}</span>
|
||||
<Icon name="arrow-right" size="1rem" class="chevron" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.phone {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.5rem;
|
||||
padding: 0 1.25rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 1rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.switch-row .chevron) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import About from "$lib/components/settings/About.svelte";
|
||||
import Appearance from "$lib/components/settings/Appearance.svelte";
|
||||
import MyAccount from "$lib/components/settings/MyAccount.svelte";
|
||||
import SettingsItem from "$lib/components/settings/SettingsItem.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { ui } from "$lib/stores/ui.svelte";
|
||||
|
||||
const features = [
|
||||
{ icon: "search", label: "Поиск" },
|
||||
{ icon: "folder", label: "Папки" },
|
||||
{ icon: "stats", label: "Presence и аналитика" },
|
||||
{ icon: "unmute", label: "Алерты" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
<header class="settings-header">
|
||||
<Button
|
||||
variant="translucent"
|
||||
round
|
||||
smaller
|
||||
onclick={() => ui.closeSettings()}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<Icon name="arrow-left" />
|
||||
</Button>
|
||||
<h2>Настройки</h2>
|
||||
</header>
|
||||
|
||||
<div class="settings-body custom-scroll">
|
||||
<MyAccount />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Внешний вид</div>
|
||||
<Appearance />
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<SettingsItem
|
||||
icon="settings"
|
||||
label="Политика захвата"
|
||||
onclick={() => ui.openPanel("policy")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="data"
|
||||
label="Данные и хранилище"
|
||||
onclick={() => ui.openPanel("jobs")}
|
||||
/>
|
||||
{#each features as feature (feature.label)}
|
||||
<SettingsItem icon={feature.icon} label={feature.label} soon />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
height: var(--header-height);
|
||||
padding: 0 0.625rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
label: string;
|
||||
onclick?: () => void;
|
||||
soon?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let { icon, label, value, soon = false, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="settings-item"
|
||||
class:soon
|
||||
use:ripple
|
||||
disabled={soon}
|
||||
{onclick}
|
||||
>
|
||||
<Icon name={icon} size="1.5rem" class="leading" />
|
||||
<span class="label">{label}</span>
|
||||
{#if soon}
|
||||
<span class="soon-tag">скоро</span>
|
||||
{:else if value}
|
||||
<span class="value">{value}</span>
|
||||
{/if}
|
||||
{#if onclick && !soon}
|
||||
<Icon name="arrow-right" size="1rem" class="chevron" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.settings-item {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.5rem;
|
||||
padding: 0 1.25rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 1rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.settings-item .leading) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.soon-tag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
:global(.settings-item .chevron) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@ export function peerName(peer: PeerView | null): string {
|
||||
return "";
|
||||
}
|
||||
if (peer.is_deleted_account) {
|
||||
return "Deleted Account";
|
||||
return "Удалённый аккаунт";
|
||||
}
|
||||
const parts = [peer.first_name, peer.last_name].filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
@@ -20,7 +20,7 @@ export function peerName(peer: PeerView | null): string {
|
||||
if (peer.username) {
|
||||
return `@${peer.username}`;
|
||||
}
|
||||
return String(peer.peer_id);
|
||||
return "Удалённый аккаунт";
|
||||
}
|
||||
|
||||
export function accountName(account: Account): string {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PresenceSample } from "$lib/api/types";
|
||||
import { formatListDate } from "$lib/format/datetime";
|
||||
|
||||
export function formatPresence(sample: PresenceSample): string {
|
||||
switch (sample.status) {
|
||||
case "online":
|
||||
return "online";
|
||||
case "recently":
|
||||
return "last seen recently";
|
||||
case "last_week":
|
||||
return "last seen within a week";
|
||||
case "last_month":
|
||||
return "last seen within a month";
|
||||
case "long_time_ago":
|
||||
return "last seen a long time ago";
|
||||
default:
|
||||
return sample.last_online_date
|
||||
? `last seen ${formatListDate(sample.last_online_date)}`
|
||||
: "offline";
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import { enrichChat, getJob, listChats } from "$lib/api/endpoints";
|
||||
import type { Chat } from "$lib/api/types";
|
||||
import type { Chat, LiveEvent } from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { events } from "$lib/stores/events.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
const POLL_INTERVAL = 1500;
|
||||
const POLL_MAX = 12;
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function createChats() {
|
||||
let list = $state<Chat[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loading = $state(false);
|
||||
let hasMore = $state(false);
|
||||
let revision = $state(0);
|
||||
let account: number | null = null;
|
||||
let filling = false;
|
||||
const enriched = new Set<number>();
|
||||
|
||||
function syncAccount() {
|
||||
@@ -30,11 +34,71 @@ function createChats() {
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
list = await listChats({ limit: 200 });
|
||||
const page = await listChats({ limit: PAGE_SIZE });
|
||||
list = page;
|
||||
hasMore = page.length === PAGE_SIZE;
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (filling) {
|
||||
return;
|
||||
}
|
||||
filling = true;
|
||||
try {
|
||||
while (hasMore) {
|
||||
if (loading) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const before = list.length;
|
||||
await loadMore();
|
||||
if (list.length === before) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
filling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
syncAccount();
|
||||
if (account === null || loading || !loaded || !hasMore) {
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const page = await listChats({ limit: PAGE_SIZE, offset: list.length });
|
||||
const seen = new Set(list.map((chat) => chat.chat_id));
|
||||
list = [...list, ...page.filter((chat) => !seen.has(chat.chat_id))];
|
||||
hasMore = page.length === PAGE_SIZE;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyEvent(event: LiveEvent) {
|
||||
if (event.type !== "message" || !loaded) {
|
||||
return;
|
||||
}
|
||||
const message = event.message;
|
||||
const existing = list.find((chat) => chat.chat_id === message.chat_id);
|
||||
if (!existing) {
|
||||
load(true);
|
||||
return;
|
||||
}
|
||||
existing.last_date = message.date;
|
||||
existing.last_sender_id = message.sender_id;
|
||||
existing.last_text = message.text;
|
||||
existing.message_count++;
|
||||
list = [existing, ...list.filter((chat) => chat !== existing)];
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: number) {
|
||||
@@ -49,6 +113,13 @@ function createChats() {
|
||||
}
|
||||
}
|
||||
|
||||
events.subscribe(applyEvent);
|
||||
events.onReconnect(() => {
|
||||
if (loaded) {
|
||||
load(true);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get list(): Chat[] {
|
||||
return list;
|
||||
@@ -59,9 +130,13 @@ function createChats() {
|
||||
get loading(): boolean {
|
||||
return loading;
|
||||
},
|
||||
get hasMore(): boolean {
|
||||
return hasMore;
|
||||
},
|
||||
get revision(): number {
|
||||
return revision;
|
||||
},
|
||||
loadMore,
|
||||
byId(id: number): Chat | undefined {
|
||||
return list.find((chat) => chat.chat_id === id);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { LiveEvent } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const RECONNECT_DELAY = 2000;
|
||||
|
||||
type Listener = (event: LiveEvent) => void;
|
||||
|
||||
function parseFrame(block: string): LiveEvent | null {
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
try {
|
||||
return JSON.parse(line.slice(5).trim()) as LiveEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createEvents() {
|
||||
const listeners = new Set<Listener>();
|
||||
const reconnectListeners = new Set<() => void>();
|
||||
let epoch = $state(0);
|
||||
let account: number | null = null;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
function emit(event: LiveEvent) {
|
||||
for (const listener of listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
function drain(input: string): string {
|
||||
let rest = input;
|
||||
let split = rest.indexOf("\n\n");
|
||||
while (split !== -1) {
|
||||
const block = rest.slice(0, split);
|
||||
if (!block.startsWith(":")) {
|
||||
const event = parseFrame(block);
|
||||
if (event) {
|
||||
emit(event);
|
||||
}
|
||||
}
|
||||
rest = rest.slice(split + 2);
|
||||
split = rest.indexOf("\n\n");
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function consume(response: Response, signal: AbortSignal) {
|
||||
if (!response.body) {
|
||||
return;
|
||||
}
|
||||
epoch++;
|
||||
for (const listener of reconnectListeners) {
|
||||
listener();
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
buffer = drain(buffer + decoder.decode(value, { stream: true }));
|
||||
}
|
||||
}
|
||||
|
||||
async function run(accountId: number, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const response = await fetch(`${BASE}/events?account_id=${accountId}`, {
|
||||
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
|
||||
signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
await consume(response, signal);
|
||||
}
|
||||
} catch {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, RECONNECT_DELAY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get epoch(): number {
|
||||
return epoch;
|
||||
},
|
||||
subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
onReconnect(listener: () => void): () => void {
|
||||
reconnectListeners.add(listener);
|
||||
return () => reconnectListeners.delete(listener);
|
||||
},
|
||||
open(accountId: number | null) {
|
||||
if (accountId === account) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
account = accountId;
|
||||
if (accountId === null || !auth.token) {
|
||||
return;
|
||||
}
|
||||
controller = new AbortController();
|
||||
run(accountId, controller.signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const events = createEvents();
|
||||
@@ -0,0 +1,101 @@
|
||||
import { searchMessages } from "$lib/api/endpoints";
|
||||
import type { SearchHit } from "$lib/api/types";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
const MIN_LENGTH = 1;
|
||||
|
||||
function createSearch() {
|
||||
let active = $state(false);
|
||||
let query = $state("");
|
||||
let messageHits = $state<SearchHit[]>([]);
|
||||
let loading = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const trimmed = $derived(query.trim());
|
||||
const chatHits = $derived.by(() => {
|
||||
const needle = trimmed.toLowerCase();
|
||||
if (needle.length < MIN_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
return chats.list.filter((chat) =>
|
||||
(chat.title ?? "").toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
|
||||
async function run(value: string) {
|
||||
const current = ++seq;
|
||||
try {
|
||||
const hits = await searchMessages(value);
|
||||
if (current === seq) {
|
||||
messageHits = hits;
|
||||
}
|
||||
} catch {
|
||||
if (current === seq) {
|
||||
messageHits = [];
|
||||
}
|
||||
} finally {
|
||||
if (current === seq) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const value = trimmed;
|
||||
if (value.length < MIN_LENGTH) {
|
||||
seq++;
|
||||
messageHits = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
timer = setTimeout(() => {
|
||||
run(value).catch(() => undefined);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get query() {
|
||||
return query;
|
||||
},
|
||||
get trimmed() {
|
||||
return trimmed;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get messageHits() {
|
||||
return messageHits;
|
||||
},
|
||||
get chatHits() {
|
||||
return chatHits;
|
||||
},
|
||||
open() {
|
||||
active = true;
|
||||
},
|
||||
setQuery(value: string) {
|
||||
query = value;
|
||||
schedule();
|
||||
},
|
||||
close() {
|
||||
active = false;
|
||||
query = "";
|
||||
messageHits = [];
|
||||
loading = false;
|
||||
seq++;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const search = createSearch();
|
||||
@@ -10,9 +10,18 @@ export type RightPanel =
|
||||
| "stories"
|
||||
| "policy";
|
||||
|
||||
export type LeftView = "main" | "settings";
|
||||
|
||||
interface JumpTarget {
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
}
|
||||
|
||||
function createUi() {
|
||||
let rightPanel = $state<RightPanel | null>(null);
|
||||
let leftColumnOpen = $state(true);
|
||||
let leftView = $state<LeftView>("main");
|
||||
let jumpTarget = $state<JumpTarget | null>(null);
|
||||
|
||||
return {
|
||||
get rightPanel() {
|
||||
@@ -21,6 +30,24 @@ function createUi() {
|
||||
get leftColumnOpen() {
|
||||
return leftColumnOpen;
|
||||
},
|
||||
get leftView() {
|
||||
return leftView;
|
||||
},
|
||||
get jumpTarget() {
|
||||
return jumpTarget;
|
||||
},
|
||||
openSettings() {
|
||||
leftView = "settings";
|
||||
},
|
||||
closeSettings() {
|
||||
leftView = "main";
|
||||
},
|
||||
requestJump(chatId: number, messageId: number) {
|
||||
jumpTarget = { chatId, messageId };
|
||||
},
|
||||
clearJump() {
|
||||
jumpTarget = null;
|
||||
},
|
||||
openPanel(panel: RightPanel) {
|
||||
rightPanel = panel;
|
||||
},
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<script lang="ts">
|
||||
import AccountSwitcher from "$lib/components/AccountSwitcher.svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fly } from "svelte/transition";
|
||||
import ChatList from "$lib/components/ChatList.svelte";
|
||||
import FolderTabs from "$lib/components/FolderTabs.svelte";
|
||||
import RightColumn from "$lib/components/RightColumn.svelte";
|
||||
import SearchInput from "$lib/components/search/SearchInput.svelte";
|
||||
import SearchResults from "$lib/components/search/SearchResults.svelte";
|
||||
import Settings from "$lib/components/settings/Settings.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { theme } from "$lib/stores/theme.svelte";
|
||||
import { events } from "$lib/stores/events.svelte";
|
||||
import { search } from "$lib/stores/search.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
import { ui } from "$lib/stores/ui.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -15,30 +22,72 @@
|
||||
accounts.load().catch(() => toasts.error("Failed to load accounts"));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
events.open(accounts.selectedId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="Main">
|
||||
<div id="Main" class:with-right={ui.rightPanel !== null}>
|
||||
<div id="LeftColumn">
|
||||
<header class="left-header">
|
||||
<AccountSwitcher />
|
||||
<Button
|
||||
variant="translucent"
|
||||
round
|
||||
smaller
|
||||
onclick={() => theme.toggle()}
|
||||
aria-label="Toggle theme"
|
||||
{#if ui.leftView === "settings"}
|
||||
<div
|
||||
class="left-view"
|
||||
in:fly={{ x: 64, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<Icon name={theme.resolved === "dark" ? "sun" : "moon"} />
|
||||
</Button>
|
||||
</header>
|
||||
<div class="folder-tabs">
|
||||
<FolderTabs />
|
||||
</div>
|
||||
<ChatList />
|
||||
<Settings />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="left-view"
|
||||
in:fly={{ x: -64, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<header class="left-header">
|
||||
{#if search.active}
|
||||
<Button
|
||||
variant="translucent"
|
||||
round
|
||||
smaller
|
||||
onclick={() => search.close()}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<Icon name="arrow-left" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="translucent"
|
||||
round
|
||||
smaller
|
||||
onclick={() => ui.openSettings()}
|
||||
aria-label="Меню"
|
||||
>
|
||||
<Icon name="menu" />
|
||||
</Button>
|
||||
{/if}
|
||||
<SearchInput />
|
||||
</header>
|
||||
{#if search.active && search.trimmed}
|
||||
<SearchResults />
|
||||
{:else}
|
||||
<div class="folder-tabs">
|
||||
<FolderTabs />
|
||||
</div>
|
||||
<ChatList />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div id="MiddleColumn">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if ui.rightPanel !== null}
|
||||
<div
|
||||
id="RightColumn"
|
||||
transition:fly={{ x: 320, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<RightColumn />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -49,6 +98,10 @@
|
||||
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
&.with-right {
|
||||
grid-template-columns: minmax(16rem, 26.5rem) 1fr minmax(20rem, 25rem);
|
||||
}
|
||||
}
|
||||
|
||||
#LeftColumn {
|
||||
@@ -62,6 +115,15 @@
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.left-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -83,4 +145,15 @@
|
||||
height: 100%;
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
#RightColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--color-borders);
|
||||
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user