feat: web UI chat render, panels, presence + analytics

This commit is contained in:
hh
2026-05-31 19:41:37 +02:00
parent 75425d1bee
commit ed469ba8dd
83 changed files with 6034 additions and 136 deletions
+60 -16
View File
@@ -1,5 +1,6 @@
import asyncpg
from utils.read.accounts import self_user_id
from utils.read.message_view import build_message_view, load_raw, media_ref_from
from utils.read.models import (
ChatListItem,
@@ -8,6 +9,7 @@ from utils.read.models import (
MessageView,
Page,
)
from utils.read.read_receipts import read_up_to
_MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
@@ -52,35 +54,43 @@ async def list_chats(
pool: asyncpg.Pool, account_id: int, page: Page
) -> list[ChatListItem]:
rows = await pool.fetch(
"SELECT m.chat_id, count(*) AS message_count, max(m.date) AS last_date, "
"WITH ids AS ("
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1), "
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date "
"FROM messages WHERE account_id = $1 GROUP BY chat_id) "
"SELECT ids.chat_id, COALESCE(agg.message_count, 0) AS message_count, "
"agg.last_date AS last_date, "
"(SELECT p.first_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS first_name, "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS first_name, "
"(SELECT p.last_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS last_name, "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS last_name, "
"(SELECT p.username FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS username, "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS username, "
"(SELECT ch.title FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title, "
"EXISTS (SELECT 1 FROM avatars a "
"WHERE a.account_id = $1 AND a.owner_id = m.chat_id) AS has_avatar, "
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, false) "
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS is_bot, "
"WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar, "
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, "
"p.raw->>'type' = 'ChatType.BOT', false) "
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_bot, "
"(SELECT COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, "
"false) FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS is_contact, "
"(SELECT ch.raw->'chat'->>'type' = 'ChatType.CHANNEL' FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"AND ch.raw->'chat'->>'type' IS NOT NULL "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_contact, "
"(SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') "
"= 'ChatType.CHANNEL' FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
"AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') IS NOT NULL "
"ORDER BY ch.ts DESC LIMIT 1) AS is_broadcast, "
"(SELECT lm.text FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = m.chat_id "
"WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_text, "
"(SELECT lm.sender_id FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = m.chat_id "
"WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_sender_id "
"FROM messages m WHERE m.account_id = $1 "
"GROUP BY m.chat_id ORDER BY last_date DESC LIMIT $2 OFFSET $3",
"FROM ids LEFT JOIN agg ON agg.chat_id = ids.chat_id "
"ORDER BY last_date DESC NULLS LAST, ids.chat_id DESC LIMIT $2 OFFSET $3",
account_id,
page.capped_limit,
page.offset,
@@ -146,9 +156,43 @@ async def get_chat_history(
else:
views.append(_build_album(members, media_by_key))
index = end
await _apply_read_status(pool, account_id, chat_id, views)
return views
async def get_message(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MessageView | None:
row = await pool.fetchrow(
f"SELECT {_MESSAGE_COLS} FROM messages " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
account_id,
chat_id,
message_id,
)
if row is None:
return None
media_by_key = await _media_map(pool, account_id, [row])
raw = load_raw(row["raw"])
view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
await _apply_read_status(pool, account_id, chat_id, [view])
return view
async def _apply_read_status(
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
) -> None:
self_id = await self_user_id(pool, account_id)
if self_id is None:
return
marker = await read_up_to(pool, account_id, chat_id)
if marker is None:
return
for view in views:
if view.sender_id == self_id and view.message_id <= marker:
view.read = True
def _build_album(
members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record],