perf(*): bound list queries, batch fetches, harden media downloads

This commit is contained in:
hh
2026-09-01 21:38:22 +02:00
parent 9c12628304
commit 1fa4f18a9b
35 changed files with 473 additions and 138 deletions
@@ -0,0 +1,49 @@
"""read path indexes
Revision ID: a6d2f8b41c9e
Revises: f2b8d3c9a51e
Create Date: 2026-09-01 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "a6d2f8b41c9e"
down_revision: str | None = "f2b8d3c9a51e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("CREATE INDEX ix_jobs_account_id ON jobs (account_id, id DESC)")
op.execute(
"CREATE INDEX ix_messages_deleted ON messages "
"(account_id, deleted_at DESC) WHERE deleted_at IS NOT NULL"
)
op.execute(
"CREATE INDEX ix_messages_pinned ON messages "
"(account_id, chat_id, date DESC, message_id DESC) "
"WHERE raw->>'service' LIKE '%PINNED_MESSAGE%'"
)
op.execute("CREATE INDEX ix_watches_account ON watches (account_id, id DESC)")
op.execute("CREATE INDEX ix_alerts_account_ts ON alerts (account_id, ts DESC)")
op.execute(
"CREATE INDEX ix_stories_account_date ON stories "
"(account_id, date DESC NULLS LAST)"
)
op.execute(
"CREATE INDEX ix_annotations_account_msg ON annotations "
"(account_id, chat_id, message_id)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_annotations_account_msg")
op.execute("DROP INDEX IF EXISTS ix_stories_account_date")
op.execute("DROP INDEX IF EXISTS ix_alerts_account_ts")
op.execute("DROP INDEX IF EXISTS ix_watches_account")
op.execute("DROP INDEX IF EXISTS ix_messages_pinned")
op.execute("DROP INDEX IF EXISTS ix_messages_deleted")
op.execute("DROP INDEX IF EXISTS ix_jobs_account_id")
+13 -2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.read.models import DEFAULT_LIMIT, Page
router = APIRouter(prefix="/api", tags=["backfill"], route_class=DishkaRoute) router = APIRouter(prefix="/api", tags=["backfill"], route_class=DishkaRoute)
@@ -130,16 +131,26 @@ async def list_jobs(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()], account_id: Annotated[int, Query()],
status: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[JobView]: ) -> list[JobView]:
page = Page(limit=limit, offset=offset)
if status is None: if status is None:
rows = await pool.fetch( rows = await pool.fetch(
"SELECT * FROM jobs WHERE account_id = $1 ORDER BY id DESC", account_id "SELECT * FROM jobs WHERE account_id = $1 "
"ORDER BY id DESC LIMIT $2 OFFSET $3",
account_id,
page.capped_limit,
page.offset,
) )
else: else:
rows = await pool.fetch( rows = await pool.fetch(
"SELECT * FROM jobs WHERE account_id = $1 AND status = $2 ORDER BY id DESC", "SELECT * FROM jobs WHERE account_id = $1 AND status = $2 "
"ORDER BY id DESC LIMIT $3 OFFSET $4",
account_id, account_id,
status, status,
page.capped_limit,
page.offset,
) )
return [_to_view(row) for row in rows] return [_to_view(row) for row in rows]
+9
View File
@@ -10,6 +10,7 @@ from utils.policy import repository
from utils.read import chats from utils.read import chats
from utils.read.models import ( from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
MAX_LIMIT,
ChatListItem, ChatListItem,
MessageVersionView, MessageVersionView,
MessageView, MessageView,
@@ -49,6 +50,14 @@ async def list_chats(
) )
@router.get("/chats/batch")
async def get_chats_batch(
pool: FromDishka[asyncpg.Pool], account_id: AccountId, ids: Annotated[str, Query()]
) -> list[ChatListItem]:
parsed = [int(part) for part in ids.split(",") if part.strip()]
return await chats.get_chats(pool, account_id, parsed[:MAX_LIMIT])
@router.get("/chats/{chat_id}") @router.get("/chats/{chat_id}")
async def get_chat( async def get_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
+2 -1
View File
@@ -13,7 +13,7 @@ from utils.policy import repository as policy_repository
from utils.policy.defaults import TRACKING from utils.policy.defaults import TRACKING
from utils.policy.models import ScopeType from utils.policy.models import ScopeType
from utils.read import discover from utils.read import discover
from utils.read.models import DiscoverItem from utils.read.models import MAX_LIMIT, DiscoverItem
router = APIRouter(prefix="/api", tags=["discover"], route_class=DishkaRoute) router = APIRouter(prefix="/api", tags=["discover"], route_class=DishkaRoute)
@@ -68,6 +68,7 @@ async def discover_peers(
) -> list[DiscoverItem]: ) -> list[DiscoverItem]:
if not query.strip(): if not query.strip():
return [] return []
limit = min(limit, MAX_LIMIT)
items = await discover.search(pool, account_id, query, limit) items = await discover.search(pool, account_id, query, limit)
if not remote: if not remote:
return items return items
+8 -2
View File
@@ -52,9 +52,15 @@ async def chat_links(
@router.get("/calendar") @router.get("/calendar")
async def chat_calendar( async def chat_calendar(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId pool: FromDishka[asyncpg.Pool],
chat_id: int,
account_id: AccountId,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> list[DayCount]: ) -> list[DayCount]:
return await profile.daily_counts(pool, account_id, chat_id) return await profile.daily_counts(
pool, account_id, chat_id, date_from=date_from, date_to=date_to
)
@router.get("/message-at") @router.get("/message-at")
@@ -122,7 +122,7 @@ async def max_message_id(
pool: asyncpg.Pool, account_id: int, chat_id: int pool: asyncpg.Pool, account_id: int, chat_id: int
) -> int | None: ) -> int | None:
return await pool.fetchval( return await pool.fetchval(
"SELECT max(message_id) FROM messages WHERE account_id = $1 AND chat_id = $2", "SELECT last_message_id FROM chat_stats WHERE account_id = $1 AND chat_id = $2",
account_id, account_id,
chat_id, chat_id,
) )
+1
View File
@@ -31,6 +31,7 @@ class PyroClient(Client):
lang_code="en", lang_code="en",
system_lang_code="en-US", system_lang_code="en-US",
client_platform=enums.ClientPlatform.DESKTOP, client_platform=enums.ClientPlatform.DESKTOP,
max_concurrent_transmissions=4,
) )
self.capture: CaptureContext | None = None self.capture: CaptureContext | None = None
+1 -1
View File
@@ -50,7 +50,7 @@ async def _reset_media_sessions(client: Client) -> None:
async def download_bytes(client: Client, target: Downloadable) -> bytes | None: async def download_bytes(client: Client, target: Downloadable) -> bytes | None:
try: try:
buffer = await client.download_media(target, in_memory=True) buffer = await client.download_media(target, in_memory=True)
except TimeoutError: except (TimeoutError, OSError):
await _reset_media_sessions(client) await _reset_media_sessions(client)
buffer = await client.download_media(target, in_memory=True) buffer = await client.download_media(target, in_memory=True)
return buffer.getvalue() if isinstance(buffer, BytesIO) else None return buffer.getvalue() if isinstance(buffer, BytesIO) else None
@@ -69,6 +69,9 @@ class JobConsumer:
requeued = await repository.requeue_running(self.pool, self.account_id) requeued = await repository.requeue_running(self.pool, self.account_id)
if requeued: if requeued:
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]") logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
pruned = await repository.prune_finished(self.pool, self.account_id)
if pruned:
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
conn = await asyncpg.connect(dsn=env.db.connection_url) conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set()) await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set())
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]") logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
@@ -1,5 +1,5 @@
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import PeerIdInvalid from pyrogram.errors import FloodPremiumWait, FloodWait, PeerIdInvalid
from pyrogram.types import Message from pyrogram.types import Message
from userbot.modules.capture import capture_message from userbot.modules.capture import capture_message
@@ -11,6 +11,7 @@ from userbot.modules.jobs.registry import register
from userbot.modules.stt import repository as stt_repo from userbot.modules.stt import repository as stt_repo
from userbot.modules.stt import should_transcribe_on_backfill from userbot.modules.stt import should_transcribe_on_backfill
from userbot.modules.stt.gate import safe_transcribe from userbot.modules.stt.gate import safe_transcribe
from utils.logging import logger
from utils.policy.models import CaptureToggles from utils.policy.models import CaptureToggles
SAVE_EVERY = 100 SAVE_EVERY = 100
@@ -61,21 +62,30 @@ async def backfill(ctx: JobContext) -> None:
min_id = await resolve_min_id(ctx, chat_id) min_id = await resolve_min_id(ctx, chat_id)
await ctx.save_cursor({"max_id": max_id, "min_id": min_id}) await ctx.save_cursor({"max_id": max_id, "min_id": min_id})
processed = ctx.job.progress.get("processed", 0) processed = ctx.job.progress.get("processed", 0)
errors = ctx.job.progress.get("errors", 0)
self_id = client.me.id if client.me else None self_id = client.me.id if client.me else None
try: try:
async for message in client.get_chat_history( async for message in client.get_chat_history(
chat_id, max_id=max_id, min_id=min_id chat_id, max_id=max_id, min_id=min_id
): ):
await capture_message(client, message, capture, toggles) try:
await maybe_transcribe(client, capture, chat_id, message, self_id) await capture_message(client, message, capture, toggles)
await maybe_transcribe(client, capture, chat_id, message, self_id)
except (FloodWait, FloodPremiumWait, PeerIdInvalid):
raise
except Exception:
errors += 1
logger.exception(f"Backfill capture failed for {chat_id}/{message.id}")
processed += 1 processed += 1
if processed % SAVE_EVERY == 0: if processed % SAVE_EVERY == 0:
next_max = message.id - 1 next_max = message.id - 1
await ctx.save_cursor({"max_id": next_max, "min_id": min_id}) await ctx.save_cursor({"max_id": next_max, "min_id": min_id})
await ctx.report_progress({"processed": processed, "max_id": next_max}) await ctx.report_progress(
{"processed": processed, "errors": errors, "max_id": next_max}
)
if await ctx.is_canceled(): if await ctx.is_canceled():
return return
except PeerIdInvalid: except PeerIdInvalid:
await ctx.report_progress({"processed": processed, "error": "peer_id_invalid"}) await ctx.report_progress({"processed": processed, "error": "peer_id_invalid"})
return return
await ctx.report_progress({"processed": processed, "done": True}) await ctx.report_progress({"processed": processed, "errors": errors, "done": True})
@@ -65,16 +65,28 @@ async def _enrich_members(client: Client, ctx: CaptureContext, chat_id: int) ->
return return
async def _fetch_users(client: Client, ids: list[int]) -> list[User]:
try:
users = await client.get_users(ids)
except BadRequest:
users = []
for sender_id in ids:
try:
users.append(await client.get_users(sender_id))
except BadRequest:
continue
if isinstance(users, User):
return [users]
return [user for user in users if isinstance(user, User)]
async def _enrich_senders(client: Client, ctx: CaptureContext, chat_id: int) -> None: async def _enrich_senders(client: Client, ctx: CaptureContext, chat_id: int) -> None:
rows = await ctx.pool.fetch(_MISSING_SENDERS, ctx.account_id, chat_id) rows = await ctx.pool.fetch(_MISSING_SENDERS, ctx.account_id, chat_id)
ids = [row["sender_id"] for row in rows] ids = [row["sender_id"] for row in rows]
for sender_id in ids: if not ids:
try: return
user = await client.get_users(sender_id) for user in await _fetch_users(client, ids):
except BadRequest: await _save_user(ctx, user)
continue
if isinstance(user, User):
await _save_user(ctx, user)
@register("enrich_chat") @register("enrich_chat")
@@ -18,6 +18,10 @@ async def fetch_media(ctx: JobContext) -> None:
if isinstance(message, list): if isinstance(message, list):
message = message[0] if message else None message = message[0] if message else None
if message is None or message.empty: if message is None or message.empty:
return msg = f"message {chat_id}/{message_id} is unavailable"
raise RuntimeError(msg)
toggles = CaptureToggles(media=True, self_destruct_media=True) toggles = CaptureToggles(media=True, self_destruct_media=True)
await capture_media(client, message, capture, chat_id, message_id, toggles) ok = await capture_media(client, message, capture, chat_id, message_id, toggles)
if not ok:
msg = f"media download failed for {chat_id}/{message_id}"
raise RuntimeError(msg)
@@ -48,6 +48,16 @@ async def requeue_running(pool: asyncpg.Pool, account_id: int) -> int:
return int(result.split()[-1]) return int(result.split()[-1])
async def prune_finished(pool: asyncpg.Pool, account_id: int) -> int:
result = await pool.execute(
"DELETE FROM jobs WHERE account_id = $1 "
"AND status IN ('done', 'canceled', 'failed') "
"AND finished_at < now() - interval '30 days'",
account_id,
)
return int(result.split()[-1])
async def save_cursor(pool: asyncpg.Pool, job_id: int, cursor: dict[str, Any]) -> None: async def save_cursor(pool: asyncpg.Pool, job_id: int, cursor: dict[str, Any]) -> None:
await pool.execute( await pool.execute(
"UPDATE jobs SET cursor = $2::jsonb, updated_at = now() WHERE id = $1", "UPDATE jobs SET cursor = $2::jsonb, updated_at = now() WHERE id = $1",
@@ -1,14 +1,23 @@
from typing import Any from typing import Any
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import (
FileIdInvalid,
FileReferenceExpired,
FileReferenceInvalid,
FloodPremiumWait,
FloodWait,
)
from pyrogram.types import Message from pyrogram.types import Message
from userbot.modules.capture import repository from userbot.modules.capture import repository
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.download import download_bytes from userbot.modules.download import Downloadable, download_bytes
from utils.logging import logger from utils.logging import logger
from utils.policy.models import CaptureToggles from utils.policy.models import CaptureToggles
STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
_MEDIA_ATTRS = ( _MEDIA_ATTRS = (
"photo", "photo",
"video", "video",
@@ -48,6 +57,48 @@ def media_unique_id(message: Message) -> str | None:
return getattr(obj, "file_unique_id", None) if obj is not None else None return getattr(obj, "file_unique_id", None) if obj is not None else None
def _download_target(message: Message) -> Downloadable | None:
kind, obj = media_object(message)
if obj is None:
return None
return message if getattr(message, kind or "", None) is obj else obj
async def _fresh_target(
client: Client, chat_id: int, message_id: int
) -> Downloadable | None:
message = await client.get_messages(chat_id, message_id)
if not isinstance(message, Message) or message.empty:
return None
return _download_target(message)
async def _download(
client: Client, target: Downloadable | None, chat_id: int, message_id: int
) -> bytes | None:
if target is None:
return None
try:
return await download_bytes(client, target)
except TimeoutError:
logger.warning(
f"[yellow]Media download timed out for {chat_id}/{message_id}.[/]"
)
return None
except STALE_FILE_ID:
fresh = await _fresh_target(client, chat_id, message_id)
if fresh is None:
return None
try:
return await download_bytes(client, fresh)
except (TimeoutError, *STALE_FILE_ID):
logger.warning(
f"[yellow]Media download failed after refetch "
f"for {chat_id}/{message_id}.[/]"
)
return None
async def capture_media( # noqa: PLR0913 async def capture_media( # noqa: PLR0913
client: Client, client: Client,
message: Message, message: Message,
@@ -55,10 +106,10 @@ async def capture_media( # noqa: PLR0913
chat_id: int, chat_id: int,
message_id: int, message_id: int,
toggles: CaptureToggles, toggles: CaptureToggles,
) -> None: ) -> bool:
kind, obj = media_object(message) kind, obj = media_object(message)
if obj is None: if obj is None:
return return True
unique_id = getattr(obj, "file_unique_id", None) unique_id = getattr(obj, "file_unique_id", None)
ttl = getattr(obj, "ttl_seconds", None) ttl = getattr(obj, "ttl_seconds", None)
want = toggles.self_destruct_media if ttl else toggles.media want = toggles.self_destruct_media if ttl else toggles.media
@@ -66,6 +117,7 @@ async def capture_media( # noqa: PLR0913
mime = getattr(obj, "mime_type", None) mime = getattr(obj, "mime_type", None)
storage_key: str | None = None storage_key: str | None = None
downloaded = False downloaded = False
flood: FloodWait | FloodPremiumWait | None = None
if want: if want:
existing = await repository.current_media( existing = await repository.current_media(
ctx.pool, ctx.account_id, chat_id, message_id ctx.pool, ctx.account_id, chat_id, message_id
@@ -80,13 +132,11 @@ async def capture_media( # noqa: PLR0913
file_size = existing["file_size"] file_size = existing["file_size"]
downloaded = True downloaded = True
else: else:
target = message if getattr(message, kind or "", None) is obj else obj target = _download_target(message)
try: try:
data = await download_bytes(client, target) data = await _download(client, target, chat_id, message_id)
except TimeoutError: except (FloodWait, FloodPremiumWait) as exc:
logger.warning( flood = exc
f"[yellow]Media download timed out for {chat_id}/{message_id}.[/]"
)
data = None data = None
if data is not None: if data is not None:
storage_key = ctx.storage.put(data) storage_key = ctx.storage.put(data)
@@ -105,3 +155,6 @@ async def capture_media( # noqa: PLR0913
unique_id, unique_id,
downloaded=downloaded, downloaded=downloaded,
) )
if flood is not None:
raise flood
return downloaded or not want
+2 -14
View File
@@ -49,18 +49,6 @@ async def _cancel(task: asyncio.Task) -> None:
await task await task
async def _enqueue_once(pool: asyncpg.Pool, account_id: int, kind: str) -> None:
existing = await pool.fetchval(
"SELECT 1 FROM jobs WHERE account_id = $1 AND kind = $2 "
"AND status IN ('pending', 'running') LIMIT 1",
account_id,
kind,
)
if existing is None:
await enqueue(pool, account_id, kind, {})
logger.info(f"[green]Queued {kind}.[/]")
class AccountRegistry: class AccountRegistry:
def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None: def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
self._pool = pool self._pool = pool
@@ -121,8 +109,8 @@ class AccountRegistry:
client, asyncio.create_task(consumer.run()), device_model client, asyncio.create_task(consumer.run()), device_model
) )
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})") logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
await _enqueue_once(self._pool, account_id, "sync_dialogs") await enqueue(self._pool, account_id, "sync_dialogs", {})
await _enqueue_once(self._pool, account_id, "sync_contacts") await enqueue(self._pool, account_id, "sync_contacts", {})
async def _stop(self, session_name: str) -> None: async def _stop(self, session_name: str) -> None:
account = self._running.pop(session_name, None) account = self._running.pop(session_name, None)
+11 -1
View File
@@ -9,12 +9,22 @@ JOBS_CHANGED_CHANNEL = "jobs_changed"
async def enqueue( async def enqueue(
pool: asyncpg.Pool, account_id: int, kind: str, params: dict[str, Any] pool: asyncpg.Pool, account_id: int, kind: str, params: dict[str, Any]
) -> int: ) -> int:
params_json = json.dumps(params)
existing = await pool.fetchval(
"SELECT id FROM jobs WHERE account_id = $1 AND kind = $2 "
"AND params = $3::jsonb AND status IN ('pending', 'running') LIMIT 1",
account_id,
kind,
params_json,
)
if existing is not None:
return existing
job_id = await pool.fetchval( job_id = await pool.fetchval(
"INSERT INTO jobs (account_id, kind, params) " "INSERT INTO jobs (account_id, kind, params) "
"VALUES ($1, $2, $3::jsonb) RETURNING id", "VALUES ($1, $2, $3::jsonb) RETURNING id",
account_id, account_id,
kind, kind,
json.dumps(params), params_json,
) )
await pool.execute(f"NOTIFY {JOBS_CHANGED_CHANNEL}") await pool.execute(f"NOTIFY {JOBS_CHANGED_CHANNEL}")
return job_id return job_id
+10 -1
View File
@@ -30,10 +30,19 @@ RETURNING {_ACCOUNT_COLS}
""" # noqa: S608 """ # noqa: S608
_self_ids: dict[int, int] = {}
async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None: async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
return await pool.fetchval( cached = _self_ids.get(account_id)
if cached is not None:
return cached
tg_user_id = await pool.fetchval(
"SELECT tg_user_id FROM accounts WHERE account_id = $1", account_id "SELECT tg_user_id FROM accounts WHERE account_id = $1", account_id
) )
if tg_user_id is not None:
_self_ids[account_id] = tg_user_id
return tg_user_id
async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]: async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]:
+3 -1
View File
@@ -33,7 +33,7 @@ async def message_volume(
async def response_stats( async def response_stats(
pool: asyncpg.Pool, account_id: int, chat_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 365
) -> ResponseStats: ) -> ResponseStats:
self_id = await self_user_id(pool, account_id) self_id = await self_user_id(pool, account_id)
rows = await pool.fetch( rows = await pool.fetch(
@@ -43,6 +43,7 @@ async def response_stats(
"lag(date) OVER w AS prev_date " "lag(date) OVER w AS prev_date "
"FROM messages " "FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND sender_id IS NOT NULL " "WHERE account_id = $1 AND chat_id = $2 AND sender_id IS NOT NULL "
"AND date >= $4 "
"WINDOW w AS (ORDER BY date, message_id)), " "WINDOW w AS (ORDER BY date, message_id)), "
"resp AS (" "resp AS ("
"SELECT (sender_id = $3) AS is_mine, " "SELECT (sender_id = $3) AS is_mine, "
@@ -55,6 +56,7 @@ async def response_stats(
account_id, account_id,
chat_id, chat_id,
self_id, self_id,
datetime.now(UTC) - timedelta(days=days),
) )
stats = ResponseStats( stats = ResponseStats(
mine_median_seconds=None, mine_count=0, their_median_seconds=None, their_count=0 mine_median_seconds=None, mine_count=0, their_median_seconds=None, their_count=0
+1 -1
View File
@@ -21,7 +21,7 @@ WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
_AVATAR_HISTORY = """ _AVATAR_HISTORY = """
SELECT unique_id, first_seen_at, downloaded FROM avatars SELECT unique_id, first_seen_at, downloaded FROM avatars
WHERE account_id = $1 AND owner_id = $2 WHERE account_id = $1 AND owner_id = $2
ORDER BY first_seen_at DESC ORDER BY first_seen_at DESC LIMIT 200
""" """
+22 -1
View File
@@ -62,6 +62,18 @@ _ONE_ID = """
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2 WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
""" """
_MANY_IDS = """
SELECT chat_id FROM chat_stats
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
UNION
SELECT chat_id FROM dialogs
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
UNION
SELECT scope_id AS chat_id FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat'
AND scope_id = ANY($2::bigint[])
"""
_CHAT_ROWS = """ _CHAT_ROWS = """
WITH ids AS ({ids}) WITH ids AS ({ids})
SELECT ids.chat_id, SELECT ids.chat_id,
@@ -171,6 +183,15 @@ async def get_chat(
return _chat_item(row) if row is not None else None return _chat_item(row) if row is not None else None
async def get_chats(
pool: asyncpg.Pool, account_id: int, ids: list[int]
) -> list[ChatListItem]:
if not ids:
return []
rows = await pool.fetch(_CHAT_ROWS.format(ids=_MANY_IDS), account_id, ids)
return [_chat_item(row) for row in rows]
async def get_chat_history( # noqa: PLR0913 async def get_chat_history( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
@@ -308,7 +329,7 @@ async def get_message_versions(
rows = await pool.fetch( rows = await pool.fetch(
"SELECT observed_at, edit_date, text FROM message_versions " "SELECT observed_at, edit_date, text FROM message_versions "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 " "WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY observed_at", "ORDER BY observed_at LIMIT 500",
account_id, account_id,
chat_id, chat_id,
message_id, message_id,
+1 -2
View File
@@ -54,9 +54,8 @@ WITH chat_meta AS (
bool_or(is_broadcast) AS is_broadcast bool_or(is_broadcast) AS is_broadcast
FROM hits GROUP BY chat_id FROM hits GROUP BY chat_id
), counts AS ( ), counts AS (
SELECT chat_id, count(*) AS message_count FROM messages SELECT chat_id, message_count FROM chat_stats
WHERE account_id = $1 AND chat_id IN (SELECT chat_id FROM merged) WHERE account_id = $1 AND chat_id IN (SELECT chat_id FROM merged)
GROUP BY chat_id
) )
SELECT m.chat_id, m.title, m.username, m.is_bot, m.is_contact, m.is_broadcast, SELECT m.chat_id, m.title, m.username, m.is_bot, m.is_contact, m.is_broadcast,
COALESCE(c.message_count, 0) AS message_count, COALESCE(c.message_count, 0) AS message_count,
+6 -5
View File
@@ -1,7 +1,8 @@
import json
import asyncpg import asyncpg
from utils.files import media_file_name from utils.files import media_file_name
from utils.read.message_view import load_raw
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaVersionView, MediaView
MEDIA_COLS = ( MEDIA_COLS = (
@@ -40,15 +41,15 @@ async def _web_page_media_stub(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MediaView | None: ) -> MediaView | None:
row = await pool.fetchrow( row = await pool.fetchrow(
"SELECT date, raw FROM messages " "SELECT date, raw->'web_page' AS web_page FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3", "WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
account_id, account_id,
chat_id, chat_id,
message_id, message_id,
) )
if row is None: if row is None or row["web_page"] is None:
return None return None
web_page = load_raw(row["raw"]).get("web_page") web_page = json.loads(row["web_page"])
if not isinstance(web_page, dict): if not isinstance(web_page, dict):
return None return None
kind = next( kind = next(
@@ -105,7 +106,7 @@ async def get_media_versions(
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_VERSION_COLS} FROM media_versions " # noqa: S608 f"SELECT {_VERSION_COLS} FROM media_versions " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 " "WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY observed_at", "ORDER BY observed_at LIMIT 500",
account_id, account_id,
chat_id, chat_id,
message_id, message_id,
+2 -1
View File
@@ -42,7 +42,8 @@ async def get_peer_history(
rows = await pool.fetch( rows = await pool.fetch(
"SELECT observed_at, first_name, last_name, username, phone, " "SELECT observed_at, first_name, last_name, username, phone, "
"photo_unique_id, is_deleted_account FROM peer_history " "photo_unique_id, is_deleted_account FROM peer_history "
"WHERE account_id = $1 AND peer_id = $2 ORDER BY observed_at DESC", "WHERE account_id = $1 AND peer_id = $2 "
"ORDER BY observed_at DESC LIMIT 500",
account_id, account_id,
peer_id, peer_id,
) )
+5 -1
View File
@@ -1,9 +1,11 @@
from datetime import datetime from datetime import UTC, datetime, timedelta
import asyncpg import asyncpg
from utils.read.models import Page, PresenceHourly, PresenceSample from utils.read.models import Page, PresenceHourly, PresenceSample
HOURLY_DEFAULT_DAYS = 90
async def presence_history( # noqa: PLR0913 async def presence_history( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
@@ -54,6 +56,8 @@ async def presence_hourly(
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
) -> list[PresenceHourly]: ) -> list[PresenceHourly]:
if date_from is None:
date_from = datetime.now(UTC) - timedelta(days=HOURLY_DEFAULT_DAYS)
params: list[object] = [account_id, peer_id] params: list[object] = [account_id, peer_id]
where = "account_id = $1 AND peer_id = $2" where = "account_id = $1 AND peer_id = $2"
if date_from is not None: if date_from is not None:
+17 -7
View File
@@ -42,17 +42,27 @@ async def chat_links(
async def daily_counts( async def daily_counts(
pool: asyncpg.Pool, account_id: int, chat_id: int pool: asyncpg.Pool,
account_id: int,
chat_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[DayCount]: ) -> list[DayCount]:
self_id = await self_user_id(pool, account_id) self_id = await self_user_id(pool, account_id)
params: list[object] = [account_id, chat_id, self_id]
where = "account_id = $1 AND chat_id = $2"
if date_from is not None:
params.append(date_from)
where += f" AND date >= ${len(params)}"
if date_to is not None:
params.append(date_to)
where += f" AND date < ${len(params)}"
rows = await pool.fetch( rows = await pool.fetch(
"SELECT date_trunc('day', date) AS day, count(*) AS count, " "SELECT date_trunc('day', date) AS day, count(*) AS count, " # noqa: S608
"count(*) FILTER (WHERE sender_id = $3) AS outgoing FROM messages " "count(*) FILTER (WHERE sender_id = $3) AS outgoing FROM messages "
"WHERE account_id = $1 AND chat_id = $2 " f"WHERE {where} GROUP BY day ORDER BY day",
"GROUP BY day ORDER BY day", *params,
account_id,
chat_id,
self_id,
) )
return [DayCount(**dict(row)) for row in rows] return [DayCount(**dict(row)) for row in rows]
+18 -1
View File
@@ -9,10 +9,27 @@ export interface CustomEmojiAsset {
url: string; url: string;
} }
const MAX_CACHED = 500;
const ready = new Map<string, CustomEmojiAsset>(); const ready = new Map<string, CustomEmojiAsset>();
const missing = new Set<string>(); const missing = new Set<string>();
const inflight = new Map<string, Promise<CustomEmojiAsset | null>>(); const inflight = new Map<string, Promise<CustomEmojiAsset | null>>();
function remember(key: string, asset: CustomEmojiAsset) {
ready.set(key, asset);
while (ready.size > MAX_CACHED) {
const oldest = ready.keys().next();
if (oldest.done) {
return;
}
const stale = ready.get(oldest.value);
ready.delete(oldest.value);
if (stale) {
URL.revokeObjectURL(stale.url);
}
}
}
function authHeaders(): Record<string, string> { function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {}; return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
} }
@@ -34,7 +51,7 @@ async function fetchEmoji(
if (response.ok) { if (response.ok) {
const blob = await response.blob(); const blob = await response.blob();
const asset = { url: URL.createObjectURL(blob), mime: blob.type }; const asset = { url: URL.createObjectURL(blob), mime: blob.type };
ready.set(key, asset); remember(key, asset);
return asset; return asset;
} }
if (response.status === 409 && retry) { if (response.status === 409 && retry) {
+18 -2
View File
@@ -303,8 +303,14 @@ export function getMessageLinks(
}); });
} }
export function getChatCalendar(chatId: number): Promise<DayCount[]> { export function getChatCalendar(
return request<DayCount[]>(`/chats/${chatId}/calendar`, { account: true }); chatId: number,
range: { date_from?: string; date_to?: string } = {}
): Promise<DayCount[]> {
return request<DayCount[]>(`/chats/${chatId}/calendar`, {
account: true,
query: { ...range },
});
} }
export function getMessageAt(chatId: number, date: string): Promise<MessageAt> { export function getMessageAt(chatId: number, date: string): Promise<MessageAt> {
@@ -334,6 +340,16 @@ export function getPeers(ids: number[]): Promise<PeerView[]> {
}); });
} }
export function getChatsBatch(ids: number[]): Promise<Chat[]> {
if (ids.length === 0) {
return Promise.resolve([]);
}
return request<Chat[]>("/chats/batch", {
account: true,
query: { ids: ids.join(",") },
});
}
export function enrichChat(chatId: number): Promise<{ job_id: number }> { export function enrichChat(chatId: number): Promise<{ job_id: number }> {
return request<{ job_id: number }>(`/chats/${chatId}/enrich`, { return request<{ job_id: number }>(`/chats/${chatId}/enrich`, {
method: "POST", method: "POST",
+23 -2
View File
@@ -102,9 +102,30 @@ export function visualKind(kind: string): VisualKind {
return "other"; return "other";
} }
const MAX_CACHED = 200;
const ready = new Map<string, InlineMedia>(); const ready = new Map<string, InlineMedia>();
const inflight = new Map<string, Promise<InlineMedia>>(); const inflight = new Map<string, Promise<InlineMedia>>();
function remember(
cache: Map<string, InlineMedia>,
key: string,
item: InlineMedia
) {
cache.set(key, item);
while (cache.size > MAX_CACHED) {
const oldest = cache.keys().next();
if (oldest.done) {
return;
}
const stale = cache.get(oldest.value);
cache.delete(oldest.value);
if (stale?.state === "ready") {
URL.revokeObjectURL(stale.url);
}
}
}
function cacheKey(account: number, chatId: number, messageId: number): string { function cacheKey(account: number, chatId: number, messageId: number): string {
return `${account}:${chatId}:${messageId}`; return `${account}:${chatId}:${messageId}`;
} }
@@ -183,7 +204,7 @@ export function loadMediaItem(media: MediaRef): Promise<InlineMedia> {
const promise = resolveById(media) const promise = resolveById(media)
.then((result) => { .then((result) => {
if (result.state === "ready") { if (result.state === "ready") {
byId.set(key, result); remember(byId, key, result);
} }
return result; return result;
}) })
@@ -251,7 +272,7 @@ export function loadInlineMedia(
const promise = resolve(chatId, messageId) const promise = resolve(chatId, messageId)
.then((result) => { .then((result) => {
if (result.state === "ready") { if (result.state === "ready") {
ready.set(key, result); remember(ready, key, result);
} }
return result; return result;
}) })
+18 -1
View File
@@ -3,10 +3,27 @@ import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api"; const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const MAX_CACHED = 100;
const ready = new Map<string, string>(); const ready = new Map<string, string>();
const missing = new Set<string>(); const missing = new Set<string>();
const inflight = new Map<string, Promise<string | null>>(); const inflight = new Map<string, Promise<string | null>>();
function remember(key: string, url: string) {
ready.set(key, url);
while (ready.size > MAX_CACHED) {
const oldest = ready.keys().next();
if (oldest.done) {
return;
}
const stale = ready.get(oldest.value);
ready.delete(oldest.value);
if (stale) {
URL.revokeObjectURL(stale);
}
}
}
function authHeaders(): Record<string, string> { function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {}; return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
} }
@@ -21,7 +38,7 @@ async function fetchStoryMedia(
const response = await fetch(url, { headers: authHeaders() }); const response = await fetch(url, { headers: authHeaders() });
if (response.ok) { if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob()); const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl); remember(key, objectUrl);
return objectUrl; return objectUrl;
} }
missing.add(key); missing.add(key);
@@ -30,6 +30,7 @@
const SCROLL_THRESHOLD = 160; const SCROLL_THRESHOLD = 160;
const STICK_OFFSET = 9; const STICK_OFFSET = 9;
const IDLE_DELAY = 1500; const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360;
let messages = $state<MessageView[]>([]); let messages = $state<MessageView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -176,6 +177,27 @@
} }
} }
function trimTail() {
if (messages.length <= MAX_MESSAGES) {
return;
}
messages = messages.slice(0, MAX_MESSAGES);
hasNewer = true;
}
async function trimHead() {
if (messages.length <= MAX_MESSAGES || container === null) {
return;
}
const el = container;
const prevHeight = el.scrollHeight;
const prevTop = el.scrollTop;
messages = messages.slice(messages.length - MAX_MESSAGES);
hasMore = true;
await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
}
async function loadOlder() { async function loadOlder() {
if ( if (
loadingOlder || loadingOlder ||
@@ -204,6 +226,7 @@
ensurePeers(fresh); ensurePeers(fresh);
await tick(); await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight); el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
trimTail();
} }
} finally { } finally {
loadingOlder = false; loadingOlder = false;
@@ -230,6 +253,7 @@
messages = [...messages, ...fresh]; messages = [...messages, ...fresh];
ensurePeers(fresh); ensurePeers(fresh);
await tick(); await tick();
await trimHead();
} }
} finally { } finally {
loadingNewer = false; loadingNewer = false;
@@ -321,6 +345,7 @@
const stick = isNearBottom(); const stick = isNearBottom();
messages = [...messages, message]; messages = [...messages, message];
ensurePeers([message]); ensurePeers([message]);
await trimHead();
if (stick) { if (stick) {
await tick(); await tick();
scrollToBottom(); scrollToBottom();
@@ -391,6 +416,7 @@
...appended, ...appended,
]; ];
ensurePeers(fresh); ensurePeers(fresh);
await trimHead();
if (appended.length > 0 && stick) { if (appended.length > 0 && stick) {
await tick(); await tick();
scrollToBottom(); scrollToBottom();
@@ -92,11 +92,11 @@
}); });
$effect(() => { $effect(() => {
for (const policy of chatPolicies) { chats.ensureMany(
if (policy.scope_id !== null) { chatPolicies
chats.ensure(policy.scope_id); .map((policy) => policy.scope_id)
} .filter((id): id is number => id !== null)
} );
}); });
function folderTitle(id: number | null): string { function folderTitle(id: number | null): string {
@@ -2,7 +2,6 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { getChatCalendar, getMessageAt } from "$lib/api/endpoints"; import { getChatCalendar, getMessageAt } from "$lib/api/endpoints";
import type { DayCount } from "$lib/api/types"; import type { DayCount } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
@@ -46,6 +45,13 @@
let days = $state<DayCount[]>([]); let days = $state<DayCount[]>([]);
let loading = $state(false); let loading = $state(false);
let cursor = $state<YearMonth | null>(null); let cursor = $state<YearMonth | null>(null);
const cache = new Map<string, DayCount[]>();
const now = new Date();
const currentMonth: YearMonth = {
year: now.getUTCFullYear(),
month: now.getUTCMonth(),
};
const byKey = $derived( const byKey = $derived(
new Map(days.map((day) => [day.day.slice(0, 10), day.count])) new Map(days.map((day) => [day.day.slice(0, 10), day.count]))
@@ -54,33 +60,27 @@
days.reduce((max, day) => Math.max(max, day.count), 0) days.reduce((max, day) => Math.max(max, day.count), 0)
); );
const minMonth = $derived.by<YearMonth | null>(() => { const view = $derived(cursor ?? currentMonth);
if (days.length === 0) {
return null;
}
const date = new Date(days[0].day);
return { year: date.getUTCFullYear(), month: date.getUTCMonth() };
});
const maxMonth = $derived.by<YearMonth | null>(() => {
if (days.length === 0) {
return null;
}
const date = new Date(days.at(-1)?.day ?? days[0].day);
return { year: date.getUTCFullYear(), month: date.getUTCMonth() };
});
const view = $derived(cursor ?? maxMonth);
function index(value: YearMonth): number { function index(value: YearMonth): number {
return value.year * 12 + value.month; return value.year * 12 + value.month;
} }
const canPrev = $derived( const canNext = $derived(index(view) < index(currentMonth));
Boolean(view && minMonth && index(view) > index(minMonth))
); function monthKey(value: YearMonth): string {
const canNext = $derived( return `${value.year}-${value.month}`;
Boolean(view && maxMonth && index(view) < index(maxMonth)) }
);
function monthRange(value: YearMonth): {
date_from: string;
date_to: string;
} {
return {
date_from: new Date(Date.UTC(value.year, value.month, 1)).toISOString(),
date_to: new Date(Date.UTC(value.year, value.month + 1, 1)).toISOString(),
};
}
function level(count: number): number { function level(count: number): number {
if (count <= 0 || maxCount <= 0) { if (count <= 0 || maxCount <= 0) {
@@ -92,35 +92,17 @@
); );
} }
function clamp(value: YearMonth): YearMonth {
if (minMonth && index(value) < index(minMonth)) {
return minMonth;
}
if (maxMonth && index(value) > index(maxMonth)) {
return maxMonth;
}
return value;
}
function shift(months: number) { function shift(months: number) {
if (!view) { const total = Math.min(index(view) + months, index(currentMonth));
return; cursor = { year: Math.floor(total / 12), month: total % 12 };
}
const total = index(view) + months;
cursor = clamp({ year: Math.floor(total / 12), month: total % 12 });
} }
const lead = $derived( const lead = $derived(
view (new Date(Date.UTC(view.year, view.month, 1)).getUTCDay() + 6) % 7
? (new Date(Date.UTC(view.year, view.month, 1)).getUTCDay() + 6) % 7
: 0
); );
const blanks = $derived(Array.from({ length: lead }, (_, i) => i)); const blanks = $derived(Array.from({ length: lead }, (_, i) => i));
const cells = $derived.by<DayCell[]>(() => { const cells = $derived.by<DayCell[]>(() => {
if (!view) {
return [];
}
const count = new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate(); const count = new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate();
const result: DayCell[] = []; const result: DayCell[] = [];
for (let day = 1; day <= count; day++) { for (let day = 1; day <= count; day++) {
@@ -133,17 +115,30 @@
return result; return result;
}); });
$effect(() => {
const _id = chatId;
const _account = accounts.selectedId;
cache.clear();
cursor = null;
days = [];
});
$effect(() => { $effect(() => {
const _id = chatId; const _id = chatId;
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
} }
const target = view;
const cached = cache.get(monthKey(target));
if (cached !== undefined) {
days = cached;
return;
}
let active = true; let active = true;
loading = true; loading = true;
days = []; getChatCalendar(chatId, monthRange(target))
cursor = null;
getChatCalendar(chatId)
.then((result) => { .then((result) => {
cache.set(monthKey(target), result);
if (active) { if (active) {
days = result; days = result;
} }
@@ -175,8 +170,6 @@
{#if loading && days.length === 0} {#if loading && days.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if !view}
<EmptyState title="Нет сообщений" />
{:else} {:else}
<div class="calendar"> <div class="calendar">
<header class="nav"> <header class="nav">
@@ -184,7 +177,6 @@
type="button" type="button"
class="step" class="step"
onclick={() => shift(-12)} onclick={() => shift(-12)}
disabled={!canPrev}
aria-label="Предыдущий год" aria-label="Предыдущий год"
> >
« «
@@ -193,7 +185,6 @@
type="button" type="button"
class="step" class="step"
onclick={() => shift(-1)} onclick={() => shift(-1)}
disabled={!canPrev}
aria-label="Предыдущий месяц" aria-label="Предыдущий месяц"
> >
<Icon name="arrow-left" /> <Icon name="arrow-left" />
@@ -35,6 +35,7 @@
let items = $state<FileShare[]>([]); let items = $state<FileShare[]>([]);
let loading = $state(false); let loading = $state(false);
let filter = $state<Filter>("active"); let filter = $state<Filter>("active");
let fetchedAll = $state(false);
let expanded = $state<number | null>(null); let expanded = $state<number | null>(null);
let token = 0; let token = 0;
@@ -44,16 +45,20 @@
: items : items
); );
const activeCount = $derived( const activeCount = $derived(
items.filter((item) => shareState(item) === "active").length fetchedAll
? items.filter((item) => shareState(item) === "active").length
: items.length
); );
async function load() { async function load() {
const current = ++token; const current = ++token;
const activeOnly = filter === "active";
loading = true; loading = true;
try { try {
const rows = await listShares(); const rows = await listShares(activeOnly);
if (current === token) { if (current === token) {
items = rows; items = rows;
fetchedAll = !activeOnly;
} }
} catch { } catch {
if (current === token) { if (current === token) {
@@ -118,7 +123,7 @@
let loadedKey = ""; let loadedKey = "";
$effect(() => { $effect(() => {
const key = `${accounts.selectedId}:${shareUi.revision}`; const key = `${accounts.selectedId}:${shareUi.revision}:${filter}`;
if (accounts.selectedId !== null && key !== loadedKey) { if (accounts.selectedId !== null && key !== loadedKey) {
loadedKey = key; loadedKey = key;
load(); load();
@@ -2,7 +2,7 @@
import { untrack } from "svelte"; import { untrack } from "svelte";
import { import {
enqueueStoriesBackfill, enqueueStoriesBackfill,
getChat, getChatsBatch,
getPeers, getPeers,
getStories, getStories,
} from "$lib/api/endpoints"; } from "$lib/api/endpoints";
@@ -63,17 +63,13 @@
const chatIds = [...byPeer.keys()].filter((id) => id < 0); const chatIds = [...byPeer.keys()].filter((id) => id < 0);
const [peers, fetched] = await Promise.all([ const [peers, fetched] = await Promise.all([
getPeers(peerIds), getPeers(peerIds),
Promise.all(chatIds.map((id) => getChat(id))), getChatsBatch(chatIds),
]); ]);
if (current !== token) { if (current !== token) {
return; return;
} }
const peerById = new Map(peers.map((peer) => [peer.peer_id, peer])); const peerById = new Map(peers.map((peer) => [peer.peer_id, peer]));
const chatById = new Map( const chatById = new Map(fetched.map((item) => [item.chat_id, item]));
fetched
.filter((item) => item !== null)
.map((item) => [item.chat_id, item])
);
groups = [...byPeer.entries()].map(([peerId, stories]) => { groups = [...byPeer.entries()].map(([peerId, stories]) => {
if (peerId > 0) { if (peerId > 0) {
const peer = peerById.get(peerId) ?? null; const peer = peerById.get(peerId) ?? null;
+33 -1
View File
@@ -1,4 +1,10 @@
import { enrichChat, getChat, getJob, listChats } from "$lib/api/endpoints"; import {
enrichChat,
getChat,
getChatsBatch,
getJob,
listChats,
} from "$lib/api/endpoints";
import type { Chat, LiveEvent } from "$lib/api/types"; import type { Chat, LiveEvent } from "$lib/api/types";
import { folderContains } from "$lib/format/folders"; import { folderContains } from "$lib/format/folders";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
@@ -245,6 +251,32 @@ function createChats() {
}) })
.catch(() => resolving.delete(id)); .catch(() => resolving.delete(id));
}, },
ensureMany(ids: number[]) {
syncAccount();
if (account === null) {
return;
}
const missing = [
...new Set(ids.filter((id) => !(resolving.has(id) || known(id)))),
];
if (missing.length === 0) {
return;
}
for (const id of missing) {
resolving.add(id);
}
getChatsBatch(missing)
.then((fetched) => {
for (const chat of fetched) {
extra[chat.chat_id] = chat;
}
})
.catch(() => {
for (const id of missing) {
resolving.delete(id);
}
});
},
load(folderId: number | null = folders.selectedId) { load(folderId: number | null = folders.selectedId) {
return load(folderId, false); return load(folderId, false);
}, },