perf(api,frontend): index hot queries, paginate chats, fix realtime

This commit is contained in:
hh
2026-08-06 01:52:41 +02:00
parent 9d767d2531
commit 525ce024bc
27 changed files with 831 additions and 268 deletions
@@ -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")
@@ -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")
+6 -2
View File
@@ -34,6 +34,7 @@ from api.routers import (
watches, watches,
) )
from dependencies.container import container from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env from utils.env import env
if env.auth.token is None: if env.auth.token is None:
@@ -105,8 +106,11 @@ if _spa_dir.is_dir():
async def serve_spa(spa_path: str) -> FileResponse: async def serve_spa(spa_path: str) -> FileResponse:
candidate = (_spa_dir / spa_path).resolve() candidate = (_spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file(): if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file():
return FileResponse(candidate) immutable = spa_path.startswith("_app/immutable/")
return FileResponse(_spa_index) 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) app.add_middleware(BearerAuthMiddleware, token=_token)
+6 -1
View File
@@ -65,11 +65,16 @@ class EventHub:
return return
account_id = event.get("account_id") account_id = event.get("account_id")
chat_id = event.get("chat_id") chat_id = event.get("chat_id")
scoped = event.get("kind") == "presence"
targets = [ targets = [
sub sub
for sub in self._subscribers for sub in self._subscribers
if sub.account_id == account_id 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: if not targets:
return return
+4 -1
View File
@@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.cache import IMMUTABLE_HEADERS, SHORT_HEADERS
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar
from utils.read.models import AvatarHistoryView from utils.read.models import AvatarHistoryView
@@ -52,5 +53,7 @@ async def serve_avatar(
) )
raise HTTPException(status_code=409, detail="avatar not downloaded; fetching") raise HTTPException(status_code=409, detail="avatar not downloaded; fetching")
return FileResponse( 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,
) )
+18 -1
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.jobs import enqueue from utils.jobs import enqueue
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,
@@ -35,8 +36,24 @@ async def list_chats(
account_id: AccountId, account_id: AccountId,
limit: Limit = DEFAULT_LIMIT, limit: Limit = DEFAULT_LIMIT,
offset: Offset = 0, offset: Offset = 0,
folder_id: Annotated[int | None, Query()] = None,
search: Annotated[str | None, Query()] = None,
) -> list[ChatListItem]: ) -> 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") @router.get("/chats/{chat_id}/messages")
+3
View File
@@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS
from utils.read.media import ( from utils.read.media import (
get_media, get_media,
get_media_version, get_media_version,
@@ -47,6 +48,7 @@ async def serve_media_version(
return FileResponse( return FileResponse(
storage.url(version.storage_key), storage.url(version.storage_key),
media_type=version.mime or "application/octet-stream", media_type=version.mime or "application/octet-stream",
headers=IMMUTABLE_HEADERS,
) )
@@ -80,4 +82,5 @@ async def serve_media(
return FileResponse( return FileResponse(
storage.url(media.storage_key), storage.url(media.storage_key),
media_type=media.mime or "application/octet-stream", media_type=media.mime or "application/octet-stream",
headers=DAY_HEADERS,
) )
+4
View File
@@ -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"}
+12
View File
@@ -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] 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( async def create_policy(
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int | None, account_id: int | None,
+111 -60
View File
@@ -1,5 +1,6 @@
import asyncpg import asyncpg
from utils.policy.models import FolderSpec
from utils.read.accounts import self_user_id 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.message_view import build_message_view, load_raw, media_ref_from
from utils.read.models import ( from utils.read.models import (
@@ -43,69 +44,88 @@ def _single_media(
return [ref] if ref else [] return [ref] if ref else []
def _peer_title( _ALL_IDS = """
first: str | None, last: str | None, username: str | None SELECT chat_id FROM chat_stats WHERE account_id = $1
) -> str | None: UNION
name = " ".join(part for part in (first, last) if part) SELECT chat_id FROM dialogs WHERE account_id = $1
return name or username 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
"""
async def list_chats( def _folder_filter(base: int) -> str:
pool: asyncpg.Pool, account_id: int, page: Page return (
) -> list[ChatListItem]: f"NOT (chat.chat_id = ANY(${base + 1}::bigint[])) "
rows = await pool.fetch( f"AND (chat.chat_id = ANY(${base + 2}::bigint[]) "
"WITH ids AS (" f"OR (NOT ${base + 3}::bool AND CASE "
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 " f"WHEN chat.is_broadcast THEN ${base + 4}::bool "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1 " f"WHEN chat.chat_id < 0 THEN ${base + 5}::bool "
"UNION SELECT scope_id FROM capture_policy WHERE account_id = $1 " f"WHEN chat.is_bot THEN ${base + 6}::bool "
"AND scope_type = 'chat' AND scope_id IS NOT NULL), " f"WHEN chat.is_contact THEN ${base + 7}::bool "
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date " f"ELSE ${base + 8}::bool END))"
"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,
) )
items = []
for row in rows:
title = row["group_title"] or _peer_title( def _folder_params(folder: FolderSpec) -> list[object]:
row["first_name"], row["last_name"], row["username"] return [
) sorted(folder.exclude_ids),
items.append( sorted(folder.include_ids | folder.pinned_ids),
ChatListItem( 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"], chat_id=row["chat_id"],
title=title, title=row["title"],
kind="private" if row["chat_id"] > 0 else "group", kind="private" if row["chat_id"] > 0 else "group",
has_avatar=row["has_avatar"], has_avatar=row["has_avatar"],
is_bot=bool(row["is_bot"]), is_bot=bool(row["is_bot"]),
@@ -116,8 +136,39 @@ async def list_chats(
last_text=row["last_text"], last_text=row["last_text"],
last_sender_id=row["last_sender_id"], last_sender_id=row["last_sender_id"],
) )
async def list_chats(
pool: asyncpg.Pool,
account_id: int,
page: Page,
*,
folder: FolderSpec | None = None,
search: str | None = None,
) -> list[ChatListItem]:
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"
) )
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 async def get_chat_history( # noqa: PLR0913
+19 -2
View File
@@ -6,6 +6,8 @@ const RETRY_DELAY = 2500;
export type AvatarKind = "peer" | "chat"; export type AvatarKind = "peer" | "chat";
const MAX_CACHED = 240;
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>>();
@@ -14,6 +16,21 @@ function cacheKey(account: number, kind: AvatarKind, id: number): string {
return `${account}:${kind}:${id}`; 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<string, string> { function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {}; return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
} }
@@ -35,7 +52,7 @@ async function fetchAvatar(
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;
} }
if (response.status === 409 && retry) { if (response.status === 409 && retry) {
@@ -85,7 +102,7 @@ async function fetchVariant(
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;
} }
if (response.status === 409 && retry) { if (response.status === 409 && retry) {
+10 -1
View File
@@ -99,10 +99,19 @@ export function logoutAccount(accountId: number): Promise<void> {
return request<void>(`/accounts/${accountId}`, { method: "DELETE" }); return request<void>(`/accounts/${accountId}`, { method: "DELETE" });
} }
export function listChats(page: Page = {}): Promise<Chat[]> { interface ChatPage extends Page {
folder_id?: number;
search?: string;
}
export function listChats(page: ChatPage = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } }); return request<Chat[]>("/chats", { account: true, query: { ...page } });
} }
export function getChat(chatId: number): Promise<Chat | null> {
return request<Chat | null>(`/chats/${chatId}`, { account: true });
}
export function listFolders(): Promise<Folder[]> { export function listFolders(): Promise<Folder[]> {
return request<Folder[]>("/folders", { account: true }); return request<Folder[]>("/folders", { account: true });
} }
+12 -16
View File
@@ -12,11 +12,10 @@
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer"; 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 { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { discover } from "$lib/stores/discover.svelte"; import { discover } from "$lib/stores/discover.svelte";
import { events } from "$lib/stores/events.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte"; import { ui } from "$lib/stores/ui.svelte";
@@ -26,6 +25,8 @@
let { chatId }: Props = $props(); let { chatId }: Props = $props();
const PRESENCE_INTERVAL = 30_000;
const isDm = $derived(chatId > 0); const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId)); const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId)); const discovered = $derived(discover.get(chatId));
@@ -85,6 +86,10 @@
} }
let active = true; let active = true;
presence = null; presence = null;
const refresh = () => {
if (document.visibilityState !== "visible") {
return;
}
getCurrentPresence(chatId) getCurrentPresence(chatId)
.then((result) => { .then((result) => {
if (active) { if (active) {
@@ -96,18 +101,12 @@
presence = null; presence = null;
} }
}); });
const unsub = events.subscribe((event) => { };
if ( refresh();
event.type === "presence" && const timer = setInterval(refresh, PRESENCE_INTERVAL);
event.peer_id === chatId &&
event.sample
) {
presence = event.sample;
}
});
return () => { return () => {
active = false; active = false;
unsub(); clearInterval(timer);
}; };
}); });
@@ -170,10 +169,7 @@
/> />
<div class="info"> <div class="info">
<h2 class="title">{title}</h2> <h2 class="title">{title}</h2>
<span <span class="subtitle" class:online={isDm && isOnline(presence)}>
class="subtitle"
class:online={isDm && presence?.status === "online"}
>
{subtitle} {subtitle}
</span> </span>
</div> </div>
+78 -30
View File
@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import { cubicOut } from "svelte/easing"; import { untrack } from "svelte";
import { fly } from "svelte/transition";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import ChatListItem from "$lib/components/ChatListItem.svelte"; import ChatListItem from "$lib/components/ChatListItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Skeleton from "$lib/components/ui/Skeleton.svelte"; import Skeleton from "$lib/components/ui/Skeleton.svelte";
import { folderContains } from "$lib/format/folders";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { folders } from "$lib/stores/folders.svelte"; import { folders } from "$lib/stores/folders.svelte";
@@ -14,37 +12,94 @@
const skeletonRows = Array.from({ length: 9 }, (_, index) => index); const skeletonRows = Array.from({ length: 9 }, (_, index) => index);
const DEFAULT_ROW_HEIGHT = 72;
const OVERSCAN = 6;
const SCROLL_THRESHOLD = 600;
const activeChatId = $derived( const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
); );
const selectedFolder = $derived(folders.selected); let viewport = $state<HTMLDivElement | null>(null);
const visibleChats = $derived( let viewportHeight = $state(0);
selectedFolder === null let scrollTop = $state(0);
? chats.list let rowHeight = $state(DEFAULT_ROW_HEIGHT);
: chats.list.filter((chat) => folderContains(selectedFolder, chat)) let frame = 0;
);
const SCROLL_THRESHOLD = 600; const list = $derived(chats.list);
const start = $derived(
Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
);
const visible = $derived(
list.slice(
start,
start + Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2
)
);
const padTop = $derived(start * rowHeight);
const padBottom = $derived(
Math.max(0, (list.length - start - visible.length) * rowHeight)
);
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
} }
chats.load().catch(() => toasts.error("Failed to load chats")); untrack(() => folders.load()).catch(() =>
folders.load().catch(() => toasts.error("Failed to load folders")); toasts.error("Failed to load folders")
);
}); });
$effect(() => {
const folderId = folders.selectedId;
if (accounts.selectedId === null) {
return;
}
if (viewport) {
viewport.scrollTop = 0;
scrollTop = 0;
}
untrack(() => chats.load(folderId)).catch(() =>
toasts.error("Failed to load chats")
);
});
$effect(() => {
if (visible.length === 0 || !viewport) {
return;
}
const row = viewport.querySelector<HTMLElement>(".Chat");
if (row && row.offsetHeight > 0 && row.offsetHeight !== rowHeight) {
rowHeight = row.offsetHeight;
}
});
function measure(el: HTMLElement) {
scrollTop = el.scrollTop;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
chats.loadMore(folders.selectedId).catch(() => undefined);
}
}
function onScroll(event: Event) { function onScroll(event: Event) {
const el = event.currentTarget as HTMLElement; const el = event.currentTarget as HTMLElement;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) { if (frame) {
chats.loadMore().catch(() => undefined); return;
} }
frame = requestAnimationFrame(() => {
frame = 0;
measure(el);
});
} }
</script> </script>
<div class="chat-list custom-scroll" onscroll={onScroll}> <div
{#if chats.loading && chats.list.length === 0} bind:this={viewport}
bind:clientHeight={viewportHeight}
class="chat-list custom-scroll"
onscroll={onScroll}
>
{#if chats.loading && list.length === 0}
{#each skeletonRows as index (index)} {#each skeletonRows as index (index)}
<div class="row-skeleton"> <div class="row-skeleton">
<Skeleton width="3rem" height="3rem" circle /> <Skeleton width="3rem" height="3rem" circle />
@@ -54,30 +109,23 @@
</div> </div>
</div> </div>
{/each} {/each}
{:else if chats.list.length === 0} {:else if list.length === 0}
<EmptyState title="No chats yet" />
{:else}
{#key folders.selectedId}
<div
class="folder-view"
in:fly={{ x: folders.direction * 24, duration: 200, easing: cubicOut }}
>
{#if visibleChats.length === 0 && !chats.hasMore}
<EmptyState <EmptyState
title="Empty folder" title={folders.selectedId === null ? "No chats yet" : "Empty folder"}
description="No chats match this folder yet" description={folders.selectedId === null
? undefined
: "No chats match this folder yet"}
/> />
{:else} {:else}
{#each visibleChats as chat (chat.chat_id)} <div style:padding-top="{padTop}px" style:padding-bottom="{padBottom}px">
{#each visible as chat (chat.chat_id)}
<ChatListItem <ChatListItem
{chat} {chat}
selected={chat.chat_id === activeChatId} selected={chat.chat_id === activeChatId}
onclick={() => goto(`/app/${chat.chat_id}`)} onclick={() => goto(`/app/${chat.chat_id}`)}
/> />
{/each} {/each}
{/if}
</div> </div>
{/key}
{/if} {/if}
</div> </div>
@@ -119,6 +119,7 @@
gap: 0.625rem; gap: 0.625rem;
width: 100%; width: 100%;
height: 4.5rem;
padding: 0.5625rem 0.5rem; padding: 0.5625rem 0.5rem;
border: 0; border: 0;
border-radius: 0.625rem; border-radius: 0.625rem;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick, untrack } from "svelte";
import { listMessages } from "$lib/api/endpoints"; import { listMessages } from "$lib/api/endpoints";
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media"; import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
import type { LiveEvent, MessageView } from "$lib/api/types"; import type { LiveEvent, MessageView } from "$lib/api/types";
@@ -13,7 +13,6 @@
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatDay } from "$lib/format/datetime"; import { formatDay } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { events } from "$lib/stores/events.svelte"; import { events } from "$lib/stores/events.svelte";
import { peers } from "$lib/stores/peers.svelte"; import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -425,14 +424,10 @@
}); });
$effect(() => { $effect(() => {
const deps = { if (accounts.selectedId === null) {
account: accounts.selectedId,
revision: chats.revision,
};
if (deps.account === null) {
return; return;
} }
loadInitial(); untrack(() => loadInitial());
}); });
</script> </script>
@@ -9,6 +9,7 @@
import JobList from "$lib/components/jobs/JobList.svelte"; import JobList from "$lib/components/jobs/JobList.svelte";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.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 { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -23,13 +24,18 @@
let syncing = $state(false); let syncing = $state(false);
let syncingContacts = $state(false); let syncingContacts = $state(false);
const availableChats = $derived( const picker = createChatPicker();
chats.list const availableChats = $derived(picker.results);
.filter((c) =>
(c.title ?? "").toLowerCase().includes(filter.trim().toLowerCase()) $effect(() => {
) picker.search(filter);
.slice(0, 40) });
);
$effect(() => {
if (selected !== null) {
chats.ensure(selected);
}
});
function chatTitle(id: number | null): string { function chatTitle(id: number | null): string {
if (id === null) { if (id === null) {
@@ -16,6 +16,7 @@
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";
import { createChatPicker } from "$lib/stores/chat-picker.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -79,15 +80,25 @@
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id) (f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
) )
); );
const picker = createChatPicker();
const availableChats = $derived( const availableChats = $derived(
chats.list picker.results.filter(
.filter((c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)) (c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)
.filter((c) =>
(c.title ?? "").toLowerCase().includes(chatFilter.trim().toLowerCase())
) )
.slice(0, 40)
); );
$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 { function folderTitle(id: number | null): string {
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`; return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
} }
@@ -19,6 +19,7 @@
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
$effect(() => { $effect(() => {
chats.ensure(hit.chat_id);
const ids: number[] = []; const ids: number[] = [];
if (hit.chat_id > 0) { if (hit.chat_id > 0) {
ids.push(hit.chat_id); ids.push(hit.chat_id);
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { getPeers, getStories } from "$lib/api/endpoints"; import { getChat, getPeers, getStories } from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories"; import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types"; import type { StoryView } from "$lib/api/types";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte"; import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
@@ -10,7 +10,6 @@
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { peerName } from "$lib/format/peer"; import { peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
interface Group { interface Group {
hasAvatar: boolean; hasAvatar: boolean;
@@ -50,11 +49,20 @@
} }
} }
const peerIds = [...byPeer.keys()].filter((id) => id > 0); const peerIds = [...byPeer.keys()].filter((id) => id > 0);
const peers = await getPeers(peerIds); const chatIds = [...byPeer.keys()].filter((id) => id < 0);
const [peers, fetched] = await Promise.all([
getPeers(peerIds),
Promise.all(chatIds.map((id) => getChat(id))),
]);
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(
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;
@@ -66,7 +74,7 @@
stories, stories,
}; };
} }
const chat = chats.byId(peerId); const chat = chatById.get(peerId);
return { return {
peerId, peerId,
kind: "chat" as const, kind: "chat" as const,
+18 -2
View File
@@ -1,10 +1,22 @@
import type { PresenceSample } from "$lib/api/types"; import type { PresenceSample } from "$lib/api/types";
import { formatListDate } from "$lib/format/datetime"; import { formatListDate } from "$lib/format/datetime";
export function isOnline(sample: PresenceSample | null): boolean {
if (sample === null || sample.status !== "online") {
return false;
}
if (sample.next_offline_date === null) {
return false;
}
return new Date(sample.next_offline_date).getTime() > Date.now();
}
export function formatPresence(sample: PresenceSample): string { export function formatPresence(sample: PresenceSample): string {
switch (sample.status) { switch (sample.status) {
case "online": case "online":
return "online"; return isOnline(sample)
? "online"
: lastSeen(sample.last_online_date ?? sample.ts);
case "recently": case "recently":
return "last seen recently"; return "last seen recently";
case "last_week": case "last_week":
@@ -15,7 +27,11 @@ export function formatPresence(sample: PresenceSample): string {
return "last seen a long time ago"; return "last seen a long time ago";
default: default:
return sample.last_online_date return sample.last_online_date
? `last seen ${formatListDate(sample.last_online_date)}` ? lastSeen(sample.last_online_date)
: "offline"; : "offline";
} }
} }
function lastSeen(date: string): string {
return `last seen ${formatListDate(date)}`;
}
@@ -0,0 +1,42 @@
import { listChats } from "$lib/api/endpoints";
import type { Chat } from "$lib/api/types";
const DEBOUNCE_MS = 250;
const LIMIT = 40;
export function createChatPicker() {
let results = $state<Chat[]>([]);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
async function run(query: string, current: number) {
try {
const found = await listChats({
limit: LIMIT,
search: query || undefined,
});
if (current === seq) {
results = found;
}
} catch {
if (current === seq) {
results = [];
}
}
}
return {
get results(): Chat[] {
return results;
},
search(query: string) {
const current = ++seq;
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
run(query.trim(), current).catch(() => undefined);
}, DEBOUNCE_MS);
},
};
}
+182 -76
View File
@@ -1,104 +1,178 @@
import { enrichChat, getJob, listChats } from "$lib/api/endpoints"; import { enrichChat, getChat, 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 { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { events } from "$lib/stores/events.svelte"; import { events } from "$lib/stores/events.svelte";
import { peers } from "$lib/stores/peers.svelte"; import { folders } from "$lib/stores/folders.svelte";
const POLL_INTERVAL = 1500; const POLL_INTERVAL = 1500;
const POLL_MAX = 12; const POLL_MAX = 12;
const PAGE_SIZE = 200; const PAGE_SIZE = 40;
const ALL = "all";
const EMPTY: Chat[] = [];
interface Bucket {
hasMore: boolean;
list: Chat[];
loaded: boolean;
loading: boolean;
}
function createBucket(): Bucket {
return { list: [], loaded: false, loading: false, hasMore: true };
}
function bucketKey(folderId: number | null): string {
return folderId === null ? ALL : String(folderId);
}
function createChats() { function createChats() {
let list = $state<Chat[]>([]); let buckets = $state<Record<string, Bucket>>({});
let loaded = $state(false); let extra = $state<Record<number, Chat>>({});
let loading = $state(false);
let hasMore = $state(false);
let revision = $state(0);
let account: number | null = null; let account: number | null = null;
let filling = false;
const enriched = new Set<number>(); const enriched = new Set<number>();
const resolving = new Set<number>();
function syncAccount() { function syncAccount() {
if (accounts.selectedId !== account) { if (accounts.selectedId !== account) {
account = accounts.selectedId; account = accounts.selectedId;
list = []; buckets = {};
loaded = false; extra = {};
enriched.clear(); enriched.clear();
resolving.clear();
} }
} }
async function load(force: boolean) { function activeKey(): string {
syncAccount(); return bucketKey(folders.selectedId);
if (account === null || (loaded && !force)) {
return;
}
loading = true;
try {
const page = await listChats({ limit: PAGE_SIZE });
list = page;
hasMore = page.length === PAGE_SIZE;
loaded = true;
} finally {
loading = false;
}
loadAll();
} }
async function loadAll() { function active(): Bucket | undefined {
if (filling) { return buckets[activeKey()];
return;
} }
filling = true;
try { function folderQuery(key: string): number | undefined {
while (hasMore) { return key === ALL ? undefined : Number(key);
if (loading) { }
await new Promise((resolve) => {
setTimeout(resolve, 50); async function fetchPage(key: string, offset: number): Promise<Chat[]> {
return await listChats({
limit: PAGE_SIZE,
offset,
folder_id: folderQuery(key),
}); });
}
async function load(folderId: number | null, force: boolean) {
syncAccount();
const key = bucketKey(folderId);
buckets[key] ??= createBucket();
const bucket = buckets[key];
if (account === null || bucket.loading || (bucket.loaded && !force)) {
return;
}
bucket.loading = true;
try {
const page = await fetchPage(key, 0);
bucket.list = page;
bucket.hasMore = page.length === PAGE_SIZE;
bucket.loaded = true;
} finally {
bucket.loading = false;
}
}
async function loadMore(folderId: number | null) {
syncAccount();
const key = bucketKey(folderId);
const bucket = buckets[key];
if (
account === null ||
bucket === undefined ||
bucket.loading ||
!(bucket.loaded && bucket.hasMore)
) {
return;
}
bucket.loading = true;
try {
const page = await fetchPage(key, bucket.list.length);
const seen = new Set(bucket.list.map((chat) => chat.chat_id));
bucket.list = [
...bucket.list,
...page.filter((chat) => !seen.has(chat.chat_id)),
];
bucket.hasMore = page.length === PAGE_SIZE;
} finally {
bucket.loading = false;
}
}
function bucketsFor(chat: Chat): Bucket[] {
const matching: Bucket[] = [];
for (const [key, bucket] of Object.entries(buckets)) {
if (key === ALL) {
matching.push(bucket);
continue; continue;
} }
const before = list.length; const folder = folders.list.find(
await loadMore(); (candidate) => candidate.folder_id === Number(key)
if (list.length === before) { );
break; if (folder && folderContains(folder, chat)) {
matching.push(bucket);
} }
} }
} finally { return matching;
filling = false;
}
} }
async function loadMore() { function known(id: number): boolean {
syncAccount(); if (extra[id] !== undefined) {
if (account === null || loading || !loaded || !hasMore) { return true;
}
return Object.values(buckets).some((bucket) =>
bucket.list.some((chat) => chat.chat_id === id)
);
}
function hoist(bucket: Bucket, chat: Chat) {
bucket.list = [chat, ...bucket.list.filter((item) => item !== chat)];
}
async function insertUnknown(chatId: number) {
const chat = await getChat(chatId);
if (chat === null) {
return; return;
} }
loading = true; for (const bucket of bucketsFor(chat)) {
try { if (!bucket.list.some((item) => item.chat_id === chatId)) {
const page = await listChats({ limit: PAGE_SIZE, offset: list.length }); bucket.list = [chat, ...bucket.list];
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) { function applyEvent(event: LiveEvent) {
if (event.type !== "message" || !loaded) { if (event.type !== "message") {
return; return;
} }
const message = event.message; const message = event.message;
const existing = list.find((chat) => chat.chat_id === message.chat_id); let known = false;
if (!existing) { for (const bucket of Object.values(buckets)) {
load(true); const existing = bucket.list.find(
return; (chat) => chat.chat_id === message.chat_id
);
if (existing === undefined) {
continue;
} }
known = true;
existing.last_date = message.date; existing.last_date = message.date;
existing.last_sender_id = message.sender_id; existing.last_sender_id = message.sender_id;
existing.last_text = message.text; existing.last_text = message.text;
existing.message_count++; existing.message_count++;
list = [existing, ...list.filter((chat) => chat !== existing)]; hoist(bucket, existing);
}
if (!known && buckets[ALL]?.loaded) {
insertUnknown(message.chat_id).catch(() => undefined);
}
} }
async function waitForJob(jobId: number) { async function waitForJob(jobId: number) {
@@ -113,38 +187,69 @@ function createChats() {
} }
} }
function replaceEverywhere(chat: Chat) {
for (const bucket of Object.values(buckets)) {
const index = bucket.list.findIndex(
(item) => item.chat_id === chat.chat_id
);
if (index !== -1) {
bucket.list[index] = chat;
}
}
if (extra[chat.chat_id] !== undefined) {
extra[chat.chat_id] = chat;
}
}
events.subscribe(applyEvent); events.subscribe(applyEvent);
events.onReconnect(() => { events.onReconnect(() => {
if (loaded) { if (active()?.loaded) {
load(true); load(folders.selectedId, true);
} }
}); });
return { return {
get list(): Chat[] { get list(): Chat[] {
return list; return active()?.list ?? EMPTY;
}, },
get loaded(): boolean { get loaded(): boolean {
return loaded; return active()?.loaded ?? false;
}, },
get loading(): boolean { get loading(): boolean {
return loading; return active()?.loading ?? false;
}, },
get hasMore(): boolean { get hasMore(): boolean {
return hasMore; return active()?.hasMore ?? false;
},
get revision(): number {
return revision;
}, },
loadMore, loadMore,
byId(id: number): Chat | undefined { byId(id: number): Chat | undefined {
return list.find((chat) => chat.chat_id === id); for (const bucket of Object.values(buckets)) {
const found = bucket.list.find((chat) => chat.chat_id === id);
if (found !== undefined) {
return found;
}
}
return extra[id];
}, },
load() { ensure(id: number) {
return load(false); syncAccount();
if (account === null || resolving.has(id) || known(id)) {
return;
}
resolving.add(id);
getChat(id)
.then((chat) => {
if (chat !== null) {
extra[id] = chat;
}
})
.catch(() => resolving.delete(id));
}, },
refresh() { load(folderId: number | null = folders.selectedId) {
return load(true); return load(folderId, false);
},
refresh(folderId: number | null = folders.selectedId) {
return load(folderId, true);
}, },
async enrich(chatId: number) { async enrich(chatId: number) {
syncAccount(); syncAccount();
@@ -155,9 +260,10 @@ function createChats() {
try { try {
const { job_id } = await enrichChat(chatId); const { job_id } = await enrichChat(chatId);
await waitForJob(job_id); await waitForJob(job_id);
peers.reset(); const chat = await getChat(chatId);
await load(true); if (chat !== null) {
revision++; replaceEverywhere(chat);
}
} catch { } catch {
enriched.delete(chatId); enriched.delete(chatId);
} }
+48 -10
View File
@@ -1,11 +1,15 @@
import { browser } from "$app/environment";
import type { LiveEvent } from "$lib/api/types"; import type { LiveEvent } from "$lib/api/types";
import { auth } from "$lib/stores/auth.svelte"; 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 RECONNECT_DELAY = 2000; const RECONNECT_DELAY = 2000;
const STALL_TIMEOUT = 45_000;
type Listener = (event: LiveEvent) => void; type Listener = (event: LiveEvent) => void;
const STALLED = Symbol("stalled");
function parseFrame(block: string): LiveEvent | null { function parseFrame(block: string): LiveEvent | null {
for (const line of block.split("\n")) { for (const line of block.split("\n")) {
if (line.startsWith("data:")) { if (line.startsWith("data:")) {
@@ -25,6 +29,7 @@ function createEvents() {
let epoch = $state(0); let epoch = $state(0);
let account: number | null = null; let account: number | null = null;
let controller: AbortController | null = null; let controller: AbortController | null = null;
let lastFrameAt = 0;
function emit(event: LiveEvent) { function emit(event: LiveEvent) {
for (const listener of listeners) { for (const listener of listeners) {
@@ -49,11 +54,24 @@ function createEvents() {
return rest; return rest;
} }
function readWithTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>
): Promise<ReadableStreamReadResult<Uint8Array> | typeof STALLED> {
let timer: ReturnType<typeof setTimeout>;
const stall = new Promise<typeof STALLED>((resolve) => {
timer = setTimeout(() => resolve(STALLED), STALL_TIMEOUT);
});
return Promise.race([reader.read(), stall]).finally(() => {
clearTimeout(timer);
});
}
async function consume(response: Response, signal: AbortSignal) { async function consume(response: Response, signal: AbortSignal) {
if (!response.body) { if (!response.body) {
return; return;
} }
epoch++; epoch++;
lastFrameAt = Date.now();
for (const listener of reconnectListeners) { for (const listener of reconnectListeners) {
listener(); listener();
} }
@@ -61,11 +79,16 @@ function createEvents() {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
while (!signal.aborted) { while (!signal.aborted) {
const { value, done } = await reader.read(); const result = await readWithTimeout(reader);
if (done) { if (result === STALLED) {
await reader.cancel().catch(() => undefined);
return; return;
} }
buffer = drain(buffer + decoder.decode(value, { stream: true })); if (result.done) {
return;
}
lastFrameAt = Date.now();
buffer = drain(buffer + decoder.decode(result.value, { stream: true }));
} }
} }
@@ -98,6 +121,22 @@ function createEvents() {
controller = null; controller = null;
} }
function start(accountId: number) {
close();
account = accountId;
controller = new AbortController();
run(accountId, controller.signal);
}
if (browser) {
document.addEventListener("visibilitychange", () => {
const stale = Date.now() - lastFrameAt > STALL_TIMEOUT;
if (document.visibilityState === "visible" && account !== null && stale) {
start(account);
}
});
}
return { return {
get epoch(): number { get epoch(): number {
return epoch; return epoch;
@@ -111,16 +150,15 @@ function createEvents() {
return () => reconnectListeners.delete(listener); return () => reconnectListeners.delete(listener);
}, },
open(accountId: number | null) { open(accountId: number | null) {
if (accountId === account) {
return;
}
close();
account = accountId;
if (accountId === null || !auth.token) { if (accountId === null || !auth.token) {
close();
account = null;
return; return;
} }
controller = new AbortController(); if (accountId === account && controller !== null) {
run(accountId, controller.signal); return;
}
start(accountId);
}, },
}; };
} }
+20 -12
View File
@@ -1,30 +1,22 @@
import { discoverPeers, searchMessages } from "$lib/api/endpoints"; import { discoverPeers, listChats, searchMessages } from "$lib/api/endpoints";
import type { DiscoverItem, SearchHit } from "$lib/api/types"; import type { Chat, DiscoverItem, SearchHit } from "$lib/api/types";
import { chats } from "$lib/stores/chats.svelte";
const DEBOUNCE_MS = 250; const DEBOUNCE_MS = 250;
const REMOTE_DEBOUNCE_MS = 700; const REMOTE_DEBOUNCE_MS = 700;
const MIN_LENGTH = 1; const MIN_LENGTH = 1;
const CHAT_LIMIT = 30;
function createSearch() { function createSearch() {
let active = $state(false); let active = $state(false);
let query = $state(""); let query = $state("");
let messageHits = $state<SearchHit[]>([]); let messageHits = $state<SearchHit[]>([]);
let chatHits = $state<Chat[]>([]);
let peerResults = $state<DiscoverItem[]>([]); let peerResults = $state<DiscoverItem[]>([]);
let loading = $state(false); let loading = $state(false);
let timers: ReturnType<typeof setTimeout>[] = []; let timers: ReturnType<typeof setTimeout>[] = [];
let seq = 0; let seq = 0;
const trimmed = $derived(query.trim()); 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)
);
});
const peerHits = $derived.by(() => { const peerHits = $derived.by(() => {
const shown = new Set(chatHits.map((chat) => chat.chat_id)); const shown = new Set(chatHits.map((chat) => chat.chat_id));
return peerResults.filter((item) => !shown.has(item.chat_id)); return peerResults.filter((item) => !shown.has(item.chat_id));
@@ -47,6 +39,19 @@ function createSearch() {
} }
} }
async function runChats(value: string, current: number) {
try {
const found = await listChats({ limit: CHAT_LIMIT, search: value });
if (current === seq) {
chatHits = found;
}
} catch {
if (current === seq) {
chatHits = [];
}
}
}
async function runPeers(value: string, current: number, remote: boolean) { async function runPeers(value: string, current: number, remote: boolean) {
try { try {
const items = await discoverPeers(value, remote); const items = await discoverPeers(value, remote);
@@ -73,6 +78,7 @@ function createSearch() {
const current = ++seq; const current = ++seq;
if (value.length < MIN_LENGTH) { if (value.length < MIN_LENGTH) {
messageHits = []; messageHits = [];
chatHits = [];
peerResults = []; peerResults = [];
loading = false; loading = false;
return; return;
@@ -81,6 +87,7 @@ function createSearch() {
timers.push( timers.push(
setTimeout(() => { setTimeout(() => {
runMessages(value, current).catch(() => undefined); runMessages(value, current).catch(() => undefined);
runChats(value, current).catch(() => undefined);
runPeers(value, current, false).catch(() => undefined); runPeers(value, current, false).catch(() => undefined);
}, DEBOUNCE_MS), }, DEBOUNCE_MS),
setTimeout(() => { setTimeout(() => {
@@ -122,6 +129,7 @@ function createSearch() {
active = false; active = false;
query = ""; query = "";
messageHits = []; messageHits = [];
chatHits = [];
peerResults = []; peerResults = [];
loading = false; loading = false;
seq++; seq++;
+2 -1
View File
@@ -11,6 +11,7 @@
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
import { events } from "$lib/stores/events.svelte"; import { events } from "$lib/stores/events.svelte";
import { search } from "$lib/stores/search.svelte"; import { search } from "$lib/stores/search.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -27,7 +28,7 @@
}); });
$effect(() => { $effect(() => {
events.open(accounts.selectedId); events.open(auth.token === null ? null : accounts.selectedId);
}); });
</script> </script>
@@ -11,6 +11,7 @@
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
} }
chats.ensure(chatId);
chats.enrich(chatId); chats.enrich(chatId);
}); });
</script> </script>