From 525ce024bcd1000e91dfbd33c185959b809ef05a Mon Sep 17 00:00:00 2001 From: h Date: Thu, 6 Aug 2026 01:52:41 +0200 Subject: [PATCH] perf(api,frontend): index hot queries, paginate chats, fix realtime --- .../versions/c1f6b3d84a92_hot_path_indexes.py | 41 +++ .../versions/d4a7e2b91f38_chat_stats.py | 119 ++++++++ backend/src/api/app.py | 8 +- backend/src/api/realtime.py | 7 +- backend/src/api/routers/avatars.py | 5 +- backend/src/api/routers/chats.py | 19 +- backend/src/api/routers/media.py | 3 + backend/src/utils/cache.py | 4 + backend/src/utils/policy/repository.py | 12 + backend/src/utils/read/chats.py | 191 ++++++++----- frontend/src/lib/api/avatars.ts | 21 +- frontend/src/lib/api/endpoints.ts | 11 +- frontend/src/lib/components/ChatHeader.svelte | 48 ++-- frontend/src/lib/components/ChatList.svelte | 126 ++++++--- .../src/lib/components/ChatListItem.svelte | 1 + .../src/lib/components/MessageList.svelte | 11 +- .../src/lib/components/jobs/JobsPanel.svelte | 20 +- .../lib/components/policy/PolicyEditor.svelte | 23 +- .../search/SearchMessageItem.svelte | 1 + .../stories/AllStoriesArchive.svelte | 16 +- frontend/src/lib/format/presence.ts | 20 +- frontend/src/lib/stores/chat-picker.svelte.ts | 42 +++ frontend/src/lib/stores/chats.svelte.ts | 256 +++++++++++++----- frontend/src/lib/stores/events.svelte.ts | 58 +++- frontend/src/lib/stores/search.svelte.ts | 32 ++- frontend/src/routes/app/+layout.svelte | 3 +- frontend/src/routes/app/[chatId]/+page.svelte | 1 + 27 files changed, 831 insertions(+), 268 deletions(-) create mode 100644 backend/migrations/versions/c1f6b3d84a92_hot_path_indexes.py create mode 100644 backend/migrations/versions/d4a7e2b91f38_chat_stats.py create mode 100644 backend/src/utils/cache.py create mode 100644 frontend/src/lib/stores/chat-picker.svelte.ts diff --git a/backend/migrations/versions/c1f6b3d84a92_hot_path_indexes.py b/backend/migrations/versions/c1f6b3d84a92_hot_path_indexes.py new file mode 100644 index 0000000..7e5e33f --- /dev/null +++ b/backend/migrations/versions/c1f6b3d84a92_hot_path_indexes.py @@ -0,0 +1,41 @@ +"""hot path indexes + +Revision ID: c1f6b3d84a92 +Revises: b9e4d1a70c26 +Create Date: 2026-08-06 12:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "c1f6b3d84a92" +down_revision: str | None = "b9e4d1a70c26" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "CREATE INDEX ix_messages_chat_date ON messages " + "(account_id, chat_id, date DESC, message_id DESC)" + ) + op.execute("CREATE INDEX ix_avatars_owner ON avatars (account_id, owner_id)") + op.execute("CREATE INDEX ix_media_message ON media (account_id, message_id)") + op.execute( + "CREATE INDEX ix_chat_history_chat_ts ON chat_history " + "(account_id, chat_id, ts DESC)" + ) + op.execute( + "CREATE INDEX ix_read_receipts_chat ON read_receipts " + "(account_id, chat_id, kind, message_id DESC)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_read_receipts_chat") + op.execute("DROP INDEX IF EXISTS ix_chat_history_chat_ts") + op.execute("DROP INDEX IF EXISTS ix_media_message") + op.execute("DROP INDEX IF EXISTS ix_avatars_owner") + op.execute("DROP INDEX IF EXISTS ix_messages_chat_date") diff --git a/backend/migrations/versions/d4a7e2b91f38_chat_stats.py b/backend/migrations/versions/d4a7e2b91f38_chat_stats.py new file mode 100644 index 0000000..e314d43 --- /dev/null +++ b/backend/migrations/versions/d4a7e2b91f38_chat_stats.py @@ -0,0 +1,119 @@ +"""chat stats + +Revision ID: d4a7e2b91f38 +Revises: c1f6b3d84a92 +Create Date: 2026-08-06 12:30:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "d4a7e2b91f38" +down_revision: str | None = "c1f6b3d84a92" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_APPLY = """ +CREATE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO chat_stats AS cs (account_id, chat_id, message_count, + last_date, last_message_id, + last_text, last_sender_id) + VALUES (NEW.account_id, NEW.chat_id, 1, + CASE WHEN NEW.date <= now() + interval '1 day' + THEN NEW.date END, + CASE WHEN NEW.date <= now() + interval '1 day' + THEN NEW.message_id END, + NEW.text, NEW.sender_id) + ON CONFLICT (account_id, chat_id) DO UPDATE SET + message_count = cs.message_count + 1, + last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED) + THEN EXCLUDED.last_date ELSE cs.last_date END, + last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED) + THEN EXCLUDED.last_message_id + ELSE cs.last_message_id END, + last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED) + THEN EXCLUDED.last_text ELSE cs.last_text END, + last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED) + THEN EXCLUDED.last_sender_id + ELSE cs.last_sender_id END; + ELSE + UPDATE chat_stats + SET last_text = NEW.text, last_sender_id = NEW.sender_id + WHERE account_id = NEW.account_id + AND chat_id = NEW.chat_id + AND last_message_id = NEW.message_id; + END IF; + RETURN NULL; +END; +$$ +""" + +_NEWER = """ +CREATE FUNCTION chat_stats_newer(current chat_stats, incoming chat_stats) +RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ + SELECT incoming.last_date IS NOT NULL + AND (current.last_date IS NULL + OR (incoming.last_date, incoming.last_message_id) + > (current.last_date, current.last_message_id)) +$$ +""" + +_BACKFILL = """ +INSERT INTO chat_stats (account_id, chat_id, message_count, last_date, + last_message_id, last_text, last_sender_id) +SELECT agg.account_id, agg.chat_id, agg.message_count, + last.date, last.message_id, last.text, last.sender_id +FROM ( + SELECT account_id, chat_id, count(*) AS message_count + FROM messages GROUP BY account_id, chat_id +) agg +LEFT JOIN LATERAL ( + SELECT date, message_id, text, sender_id FROM messages m + WHERE m.account_id = agg.account_id AND m.chat_id = agg.chat_id + AND m.date <= now() + interval '1 day' + ORDER BY m.date DESC, m.message_id DESC LIMIT 1 +) last ON true +""" + + +def upgrade() -> None: + op.execute( + "CREATE TABLE chat_stats (" + "account_id integer NOT NULL, " + "chat_id bigint NOT NULL, " + "message_count bigint NOT NULL DEFAULT 0, " + "last_date timestamptz, " + "last_message_id bigint, " + "last_text text, " + "last_sender_id bigint, " + "PRIMARY KEY (account_id, chat_id))" + ) + op.execute(_BACKFILL) + op.execute( + "CREATE INDEX ix_chat_stats_recent ON chat_stats " + "(account_id, last_date DESC, chat_id DESC)" + ) + op.execute(_NEWER) + op.execute(_APPLY) + op.execute( + "CREATE TRIGGER messages_chat_stats_insert AFTER INSERT ON messages " + "FOR EACH ROW EXECUTE FUNCTION chat_stats_apply()" + ) + op.execute( + "CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages " + "FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text " + "OR OLD.sender_id IS DISTINCT FROM NEW.sender_id) " + "EXECUTE FUNCTION chat_stats_apply()" + ) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages") + op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_insert ON messages") + op.execute("DROP FUNCTION IF EXISTS chat_stats_apply()") + op.execute("DROP FUNCTION IF EXISTS chat_stats_newer(chat_stats, chat_stats)") + op.execute("DROP TABLE IF EXISTS chat_stats") diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 7523b2d..0f16fe8 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -34,6 +34,7 @@ from api.routers import ( watches, ) from dependencies.container import container +from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS from utils.env import env if env.auth.token is None: @@ -105,8 +106,11 @@ if _spa_dir.is_dir(): async def serve_spa(spa_path: str) -> FileResponse: candidate = (_spa_dir / spa_path).resolve() if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file(): - return FileResponse(candidate) - return FileResponse(_spa_index) + immutable = spa_path.startswith("_app/immutable/") + return FileResponse( + candidate, headers=IMMUTABLE_HEADERS if immutable else DAY_HEADERS + ) + return FileResponse(_spa_index, headers=NO_STORE_HEADERS) app.add_middleware(BearerAuthMiddleware, token=_token) diff --git a/backend/src/api/realtime.py b/backend/src/api/realtime.py index 130a7db..8eb18c7 100644 --- a/backend/src/api/realtime.py +++ b/backend/src/api/realtime.py @@ -65,11 +65,16 @@ class EventHub: return account_id = event.get("account_id") chat_id = event.get("chat_id") + scoped = event.get("kind") == "presence" 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) + and ( + sub.chat_id == chat_id + if scoped + else sub.chat_id is None or sub.chat_id == chat_id + ) ] if not targets: return diff --git a/backend/src/api/routers/avatars.py b/backend/src/api/routers/avatars.py index dcc6b37..db3f215 100644 --- a/backend/src/api/routers/avatars.py +++ b/backend/src/api/routers/avatars.py @@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse +from utils.cache import IMMUTABLE_HEADERS, SHORT_HEADERS from utils.jobs import enqueue from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar from utils.read.models import AvatarHistoryView @@ -52,5 +53,7 @@ async def serve_avatar( ) raise HTTPException(status_code=409, detail="avatar not downloaded; fetching") return FileResponse( - storage.url(avatar.storage_key), media_type=avatar.mime or "image/jpeg" + storage.url(avatar.storage_key), + media_type=avatar.mime or "image/jpeg", + headers=IMMUTABLE_HEADERS if unique_id is not None else SHORT_HEADERS, ) diff --git a/backend/src/api/routers/chats.py b/backend/src/api/routers/chats.py index 5b9856e..d34ae37 100644 --- a/backend/src/api/routers/chats.py +++ b/backend/src/api/routers/chats.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Query from pydantic import BaseModel from utils.jobs import enqueue +from utils.policy import repository from utils.read import chats from utils.read.models import ( DEFAULT_LIMIT, @@ -35,8 +36,24 @@ async def list_chats( account_id: AccountId, limit: Limit = DEFAULT_LIMIT, offset: Offset = 0, + folder_id: Annotated[int | None, Query()] = None, + search: Annotated[str | None, Query()] = None, ) -> list[ChatListItem]: - return await chats.list_chats(pool, account_id, Page(limit=limit, offset=offset)) + folder = ( + await repository.get_folder(pool, account_id, folder_id) + if folder_id is not None + else None + ) + return await chats.list_chats( + pool, account_id, Page(limit=limit, offset=offset), folder=folder, search=search + ) + + +@router.get("/chats/{chat_id}") +async def get_chat( + pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId +) -> ChatListItem | None: + return await chats.get_chat(pool, account_id, chat_id) @router.get("/chats/{chat_id}/messages") diff --git a/backend/src/api/routers/media.py b/backend/src/api/routers/media.py index ceece66..b66c638 100644 --- a/backend/src/api/routers/media.py +++ b/backend/src/api/routers/media.py @@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse +from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS from utils.read.media import ( get_media, get_media_version, @@ -47,6 +48,7 @@ async def serve_media_version( return FileResponse( storage.url(version.storage_key), media_type=version.mime or "application/octet-stream", + headers=IMMUTABLE_HEADERS, ) @@ -80,4 +82,5 @@ async def serve_media( return FileResponse( storage.url(media.storage_key), media_type=media.mime or "application/octet-stream", + headers=DAY_HEADERS, ) diff --git a/backend/src/utils/cache.py b/backend/src/utils/cache.py new file mode 100644 index 0000000..35ff3ed --- /dev/null +++ b/backend/src/utils/cache.py @@ -0,0 +1,4 @@ +IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} +DAY_HEADERS = {"Cache-Control": "public, max-age=86400"} +SHORT_HEADERS = {"Cache-Control": "public, max-age=300"} +NO_STORE_HEADERS = {"Cache-Control": "no-cache"} diff --git a/backend/src/utils/policy/repository.py b/backend/src/utils/policy/repository.py index 6046bcf..5fbc738 100644 --- a/backend/src/utils/policy/repository.py +++ b/backend/src/utils/policy/repository.py @@ -85,6 +85,18 @@ async def list_folders(pool: asyncpg.Pool, account_id: int) -> list[FolderSpec]: return [_row_to_folder(row) for row in rows] +async def get_folder( + pool: asyncpg.Pool, account_id: int, folder_id: int +) -> FolderSpec | None: + row = await pool.fetchrow( + "SELECT folder_id, title, order_index, is_chatlist, raw " + "FROM folders WHERE account_id = $1 AND folder_id = $2", + account_id, + folder_id, + ) + return _row_to_folder(row) if row else None + + async def create_policy( pool: asyncpg.Pool, account_id: int | None, diff --git a/backend/src/utils/read/chats.py b/backend/src/utils/read/chats.py index 4eab81d..c04d8d1 100644 --- a/backend/src/utils/read/chats.py +++ b/backend/src/utils/read/chats.py @@ -1,5 +1,6 @@ import asyncpg +from utils.policy.models import FolderSpec 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 ( @@ -43,81 +44,131 @@ def _single_media( return [ref] if ref else [] -def _peer_title( - first: str | None, last: str | None, username: str | None -) -> str | None: - name = " ".join(part for part in (first, last) if part) - return name or username +_ALL_IDS = """ + SELECT chat_id FROM chat_stats WHERE account_id = $1 + UNION + SELECT chat_id FROM dialogs WHERE account_id = $1 + UNION + SELECT scope_id AS chat_id FROM capture_policy + WHERE account_id = $1 AND scope_type = 'chat' AND scope_id IS NOT NULL +""" + +_ONE_ID = """ + SELECT chat_id FROM chat_stats WHERE account_id = $1 AND chat_id = $2 + UNION + SELECT chat_id FROM dialogs WHERE account_id = $1 AND chat_id = $2 + UNION + SELECT scope_id AS chat_id FROM capture_policy + WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2 +""" + +_CHAT_ROWS = """ +WITH ids AS ({ids}) +SELECT ids.chat_id, + COALESCE(cs.message_count, 0) AS message_count, + cs.last_date, cs.last_text, cs.last_sender_id, + COALESCE(named.title, + NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''), + p.username) AS title, + COALESCE(typed.is_broadcast, false) AS is_broadcast, + COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, + p.raw->>'type' = 'ChatType.BOT', false) AS is_bot, + COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, + false) AS is_contact, + EXISTS (SELECT 1 FROM avatars a + WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar +FROM ids +LEFT JOIN chat_stats cs ON cs.account_id = $1 AND cs.chat_id = ids.chat_id +LEFT JOIN peers p ON p.account_id = $1 AND p.peer_id = ids.chat_id +LEFT JOIN LATERAL ( + SELECT ch.title FROM chat_history ch + 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 +) named ON true +LEFT JOIN LATERAL ( + SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') + = 'ChatType.CHANNEL' AS is_broadcast + 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 +) typed ON true +""" + + +def _folder_filter(base: int) -> str: + return ( + f"NOT (chat.chat_id = ANY(${base + 1}::bigint[])) " + f"AND (chat.chat_id = ANY(${base + 2}::bigint[]) " + f"OR (NOT ${base + 3}::bool AND CASE " + f"WHEN chat.is_broadcast THEN ${base + 4}::bool " + f"WHEN chat.chat_id < 0 THEN ${base + 5}::bool " + f"WHEN chat.is_bot THEN ${base + 6}::bool " + f"WHEN chat.is_contact THEN ${base + 7}::bool " + f"ELSE ${base + 8}::bool END))" + ) + + +def _folder_params(folder: FolderSpec) -> list[object]: + return [ + sorted(folder.exclude_ids), + sorted(folder.include_ids | folder.pinned_ids), + folder.is_chatlist, + folder.broadcasts, + folder.groups, + folder.bots, + folder.contacts, + folder.non_contacts, + ] + + +def _chat_item(row: asyncpg.Record) -> ChatListItem: + return ChatListItem( + chat_id=row["chat_id"], + title=row["title"], + kind="private" if row["chat_id"] > 0 else "group", + has_avatar=row["has_avatar"], + is_bot=bool(row["is_bot"]), + is_contact=bool(row["is_contact"]), + is_broadcast=bool(row["is_broadcast"]), + message_count=row["message_count"], + last_date=row["last_date"], + last_text=row["last_text"], + last_sender_id=row["last_sender_id"], + ) async def list_chats( - pool: asyncpg.Pool, account_id: int, page: Page + pool: asyncpg.Pool, + account_id: int, + page: Page, + *, + folder: FolderSpec | None = None, + search: str | None = None, ) -> list[ChatListItem]: - rows = await pool.fetch( - "WITH ids AS (" - "SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 " - "UNION SELECT chat_id FROM dialogs WHERE account_id = $1 " - "UNION SELECT scope_id FROM capture_policy WHERE account_id = $1 " - "AND scope_type = 'chat' AND scope_id IS NOT NULL), " - "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 = ids.chat_id) AS first_name, " - "(SELECT p.last_name FROM peers p " - "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 = ids.chat_id) AS username, " - "(SELECT ch.title FROM chat_history ch " - "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 = ids.chat_id) AS has_avatar, " - "(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, " - "p.raw->>'type' = 'ChatType.BOT', false) " - "FROM peers p WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_bot, " - "(SELECT COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, " - "false) FROM peers p " - "WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_contact, " - "(SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') " - "= 'ChatType.CHANNEL' FROM chat_history ch " - "WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id " - "AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') IS NOT NULL " - "ORDER BY ch.ts DESC LIMIT 1) AS is_broadcast, " - "(SELECT lm.text FROM messages lm " - "WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id " - "ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_text, " - "(SELECT lm.sender_id FROM messages lm " - "WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id " - "ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_sender_id " - "FROM ids LEFT JOIN agg ON agg.chat_id = ids.chat_id " - "ORDER BY last_date DESC NULLS LAST, ids.chat_id DESC LIMIT $2 OFFSET $3", - account_id, - page.capped_limit, - page.offset, + params: list[object] = [account_id, page.capped_limit, page.offset] + rows_sql = _CHAT_ROWS.format(ids=_ALL_IDS) + clauses: list[str] = [] + if folder is not None: + clauses.append(_folder_filter(len(params))) + params.extend(_folder_params(folder)) + if search: + params.append(f"%{search}%") + clauses.append(f"chat.title ILIKE ${len(params)}") + where = f" WHERE {' AND '.join(clauses)}" if clauses else "" + query = ( + f"SELECT chat.* FROM ({rows_sql}) chat{where} " # noqa: S608 + "ORDER BY last_date DESC NULLS LAST, chat_id DESC LIMIT $2 OFFSET $3" ) - items = [] - for row in rows: - title = row["group_title"] or _peer_title( - row["first_name"], row["last_name"], row["username"] - ) - items.append( - ChatListItem( - chat_id=row["chat_id"], - title=title, - kind="private" if row["chat_id"] > 0 else "group", - has_avatar=row["has_avatar"], - is_bot=bool(row["is_bot"]), - is_contact=bool(row["is_contact"]), - is_broadcast=bool(row["is_broadcast"]), - message_count=row["message_count"], - last_date=row["last_date"], - last_text=row["last_text"], - last_sender_id=row["last_sender_id"], - ) - ) - return items + rows = await pool.fetch(query, *params) + return [_chat_item(row) for row in rows] + + +async def get_chat( + pool: asyncpg.Pool, account_id: int, chat_id: int +) -> ChatListItem | None: + row = await pool.fetchrow(_CHAT_ROWS.format(ids=_ONE_ID), account_id, chat_id) + return _chat_item(row) if row is not None else None async def get_chat_history( # noqa: PLR0913 diff --git a/frontend/src/lib/api/avatars.ts b/frontend/src/lib/api/avatars.ts index aa96226..8a443fd 100644 --- a/frontend/src/lib/api/avatars.ts +++ b/frontend/src/lib/api/avatars.ts @@ -6,6 +6,8 @@ const RETRY_DELAY = 2500; export type AvatarKind = "peer" | "chat"; +const MAX_CACHED = 240; + const ready = new Map(); const missing = new Set(); const inflight = new Map>(); @@ -14,6 +16,21 @@ function cacheKey(account: number, kind: AvatarKind, id: number): string { return `${account}:${kind}:${id}`; } +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 { return auth.token ? { Authorization: `Bearer ${auth.token}` } : {}; } @@ -35,7 +52,7 @@ async function fetchAvatar( const response = await fetch(url, { headers: authHeaders() }); if (response.ok) { const objectUrl = URL.createObjectURL(await response.blob()); - ready.set(key, objectUrl); + remember(key, objectUrl); return objectUrl; } if (response.status === 409 && retry) { @@ -85,7 +102,7 @@ async function fetchVariant( const response = await fetch(url, { headers: authHeaders() }); if (response.ok) { const objectUrl = URL.createObjectURL(await response.blob()); - ready.set(key, objectUrl); + remember(key, objectUrl); return objectUrl; } if (response.status === 409 && retry) { diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 6417015..77cf490 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -99,10 +99,19 @@ export function logoutAccount(accountId: number): Promise { return request(`/accounts/${accountId}`, { method: "DELETE" }); } -export function listChats(page: Page = {}): Promise { +interface ChatPage extends Page { + folder_id?: number; + search?: string; +} + +export function listChats(page: ChatPage = {}): Promise { return request("/chats", { account: true, query: { ...page } }); } +export function getChat(chatId: number): Promise { + return request(`/chats/${chatId}`, { account: true }); +} + export function listFolders(): Promise { return request("/folders", { account: true }); } diff --git a/frontend/src/lib/components/ChatHeader.svelte b/frontend/src/lib/components/ChatHeader.svelte index 292d217..666525c 100644 --- a/frontend/src/lib/components/ChatHeader.svelte +++ b/frontend/src/lib/components/ChatHeader.svelte @@ -12,11 +12,10 @@ import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte"; import Icon from "$lib/components/ui/Icon.svelte"; import { peerName } from "$lib/format/peer"; - import { formatPresence } from "$lib/format/presence"; + import { formatPresence, isOnline } from "$lib/format/presence"; import { accounts } from "$lib/stores/accounts.svelte"; import { chats } from "$lib/stores/chats.svelte"; import { discover } from "$lib/stores/discover.svelte"; - import { events } from "$lib/stores/events.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; import { ui } from "$lib/stores/ui.svelte"; @@ -26,6 +25,8 @@ let { chatId }: Props = $props(); + const PRESENCE_INTERVAL = 30_000; + const isDm = $derived(chatId > 0); const chat = $derived(chats.byId(chatId)); const discovered = $derived(discover.get(chatId)); @@ -85,29 +86,27 @@ } 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; + const refresh = () => { + if (document.visibilityState !== "visible") { + return; } - }); + getCurrentPresence(chatId) + .then((result) => { + if (active) { + presence = result; + } + }) + .catch(() => { + if (active) { + presence = null; + } + }); + }; + refresh(); + const timer = setInterval(refresh, PRESENCE_INTERVAL); return () => { active = false; - unsub(); + clearInterval(timer); }; }); @@ -170,10 +169,7 @@ />

{title}

- + {subtitle}
diff --git a/frontend/src/lib/components/ChatList.svelte b/frontend/src/lib/components/ChatList.svelte index 128c121..321ceeb 100644 --- a/frontend/src/lib/components/ChatList.svelte +++ b/frontend/src/lib/components/ChatList.svelte @@ -1,12 +1,10 @@ -
- {#if chats.loading && chats.list.length === 0} +
+ {#if chats.loading && list.length === 0} {#each skeletonRows as index (index)}
@@ -54,30 +109,23 @@
{/each} - {:else if chats.list.length === 0} - + {:else if list.length === 0} + {:else} - {#key folders.selectedId} -
- {#if visibleChats.length === 0 && !chats.hasMore} - - {:else} - {#each visibleChats as chat (chat.chat_id)} - goto(`/app/${chat.chat_id}`)} - /> - {/each} - {/if} -
- {/key} +
+ {#each visible as chat (chat.chat_id)} + goto(`/app/${chat.chat_id}`)} + /> + {/each} +
{/if}
diff --git a/frontend/src/lib/components/ChatListItem.svelte b/frontend/src/lib/components/ChatListItem.svelte index dd2a783..a45c123 100644 --- a/frontend/src/lib/components/ChatListItem.svelte +++ b/frontend/src/lib/components/ChatListItem.svelte @@ -119,6 +119,7 @@ gap: 0.625rem; width: 100%; + height: 4.5rem; padding: 0.5625rem 0.5rem; border: 0; border-radius: 0.625rem; diff --git a/frontend/src/lib/components/MessageList.svelte b/frontend/src/lib/components/MessageList.svelte index 0966d99..4768c37 100644 --- a/frontend/src/lib/components/MessageList.svelte +++ b/frontend/src/lib/components/MessageList.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/lib/components/jobs/JobsPanel.svelte b/frontend/src/lib/components/jobs/JobsPanel.svelte index 37db46e..127d575 100644 --- a/frontend/src/lib/components/jobs/JobsPanel.svelte +++ b/frontend/src/lib/components/jobs/JobsPanel.svelte @@ -9,6 +9,7 @@ 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 { createChatPicker } from "$lib/stores/chat-picker.svelte"; import { chats } from "$lib/stores/chats.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; @@ -23,13 +24,18 @@ let syncing = $state(false); let syncingContacts = $state(false); - const availableChats = $derived( - chats.list - .filter((c) => - (c.title ?? "").toLowerCase().includes(filter.trim().toLowerCase()) - ) - .slice(0, 40) - ); + const picker = createChatPicker(); + const availableChats = $derived(picker.results); + + $effect(() => { + picker.search(filter); + }); + + $effect(() => { + if (selected !== null) { + chats.ensure(selected); + } + }); function chatTitle(id: number | null): string { if (id === null) { diff --git a/frontend/src/lib/components/policy/PolicyEditor.svelte b/frontend/src/lib/components/policy/PolicyEditor.svelte index c34d6b9..9b32311 100644 --- a/frontend/src/lib/components/policy/PolicyEditor.svelte +++ b/frontend/src/lib/components/policy/PolicyEditor.svelte @@ -16,6 +16,7 @@ import Icon from "$lib/components/ui/Icon.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; import { accounts } from "$lib/stores/accounts.svelte"; + import { createChatPicker } from "$lib/stores/chat-picker.svelte"; import { chats } from "$lib/stores/chats.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; @@ -79,15 +80,25 @@ (f) => !folderPolicies.some((p) => p.scope_id === f.folder_id) ) ); + const picker = createChatPicker(); 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) + picker.results.filter( + (c) => !chatPolicies.some((p) => p.scope_id === c.chat_id) + ) ); + $effect(() => { + picker.search(chatFilter); + }); + + $effect(() => { + for (const policy of chatPolicies) { + if (policy.scope_id !== null) { + chats.ensure(policy.scope_id); + } + } + }); + function folderTitle(id: number | null): string { return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`; } diff --git a/frontend/src/lib/components/search/SearchMessageItem.svelte b/frontend/src/lib/components/search/SearchMessageItem.svelte index e2d9f63..359994b 100644 --- a/frontend/src/lib/components/search/SearchMessageItem.svelte +++ b/frontend/src/lib/components/search/SearchMessageItem.svelte @@ -19,6 +19,7 @@ const ownId = $derived(accounts.selected?.tg_user_id ?? null); $effect(() => { + chats.ensure(hit.chat_id); const ids: number[] = []; if (hit.chat_id > 0) { ids.push(hit.chat_id); diff --git a/frontend/src/lib/components/stories/AllStoriesArchive.svelte b/frontend/src/lib/components/stories/AllStoriesArchive.svelte index f684b40..c8c2bcf 100644 --- a/frontend/src/lib/components/stories/AllStoriesArchive.svelte +++ b/frontend/src/lib/components/stories/AllStoriesArchive.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/routes/app/[chatId]/+page.svelte b/frontend/src/routes/app/[chatId]/+page.svelte index 8d48058..22d0270 100644 --- a/frontend/src/routes/app/[chatId]/+page.svelte +++ b/frontend/src/routes/app/[chatId]/+page.svelte @@ -11,6 +11,7 @@ if (accounts.selectedId === null) { return; } + chats.ensure(chatId); chats.enrich(chatId); });