From 97f9082f5e923adf8e4ad20928c8cde370e053fb Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 03:21:57 +0000 Subject: [PATCH] feat(backend): chat list marks a deleted last message, history pages mark messages with a note --- .../f1c4a8e2d6b3_chat_stats_last_deleted.py | 97 +++++++++++++++++++ backend/src/utils/read/chats.py | 24 ++++- backend/src/utils/read/models.py | 28 ++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/f1c4a8e2d6b3_chat_stats_last_deleted.py diff --git a/backend/migrations/versions/f1c4a8e2d6b3_chat_stats_last_deleted.py b/backend/migrations/versions/f1c4a8e2d6b3_chat_stats_last_deleted.py new file mode 100644 index 0000000..63210f1 --- /dev/null +++ b/backend/migrations/versions/f1c4a8e2d6b3_chat_stats_last_deleted.py @@ -0,0 +1,97 @@ +"""chat stats last deleted + +Revision ID: f1c4a8e2d6b3 +Revises: e8b3f1a6c2d4 +Create Date: 2026-10-19 04:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "f1c4a8e2d6b3" +down_revision: str | None = "e8b3f1a6c2d4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_APPLY = """ +CREATE OR REPLACE 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{column}) + 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{value}) + 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{merge}; + ELSE + UPDATE chat_stats + SET last_text = NEW.text, last_sender_id = NEW.sender_id{update} + WHERE account_id = NEW.account_id + AND chat_id = NEW.chat_id + AND last_message_id = NEW.message_id; + END IF; + RETURN NULL; +END; +$$ +""" + +_DELETED_PARTS = { + "column": ", last_deleted", + "value": ", NEW.deleted_at IS NOT NULL", + "merge": ( + ",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)" + " THEN EXCLUDED.last_deleted ELSE cs.last_deleted END" + ), + "update": ", last_deleted = NEW.deleted_at IS NOT NULL", + "deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at", +} +_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "") + +_UPDATE_TRIGGER = ( + "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{deleted}) " + "EXECUTE FUNCTION chat_stats_apply()" +) + +_BACKFILL = """ +UPDATE chat_stats cs SET last_deleted = true +FROM messages m +WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id + AND m.message_id = cs.last_message_id AND m.date = cs.last_date + AND m.deleted_at IS NOT NULL +""" + + +def upgrade() -> None: + op.execute( + "ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false" + ) + op.execute(_BACKFILL) + op.execute(_APPLY.format(**_DELETED_PARTS)) + op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages") + op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS)) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages") + op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS)) + op.execute(_APPLY.format(**_PLAIN_PARTS)) + op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted") diff --git a/backend/src/utils/read/chats.py b/backend/src/utils/read/chats.py index b27de98..e1d898d 100644 --- a/backend/src/utils/read/chats.py +++ b/backend/src/utils/read/chats.py @@ -80,7 +80,8 @@ _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, + cs.last_date, cs.last_text, cs.last_sender_id, cs.last_message_id, + COALESCE(cs.last_deleted, false) AS last_deleted, COALESCE(named.title, NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''), p.username) AS title, @@ -149,6 +150,8 @@ def _chat_item(row: asyncpg.Record) -> ChatListItem: last_date=row["last_date"], last_text=row["last_text"], last_sender_id=row["last_sender_id"], + last_message_id=row["last_message_id"], + last_deleted=row["last_deleted"], ) @@ -259,6 +262,7 @@ async def get_chat_history( # noqa: PLR0913 views.append(_build_album(members, media_by_key)) index = end await _apply_read_status(pool, account_id, chat_id, views) + await _apply_notes(pool, account_id, chat_id, views) return views @@ -278,6 +282,7 @@ async def get_message( 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]) + await _apply_notes(pool, account_id, chat_id, [view]) return view @@ -301,6 +306,23 @@ async def _apply_read_status( ) +async def _apply_notes( + pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView] +) -> None: + if not views: + return + rows = await pool.fetch( + "SELECT DISTINCT message_id FROM annotations " + "WHERE account_id = $1 AND chat_id = $2 AND message_id = ANY($3::bigint[])", + account_id, + chat_id, + [view.message_id for view in views], + ) + noted = {row["message_id"] for row in rows} + for view in views: + view.has_note = view.message_id in noted + + def _build_album( members: list[tuple[asyncpg.Record, dict]], media_by_key: dict[tuple[int, int], asyncpg.Record], diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py index 794e6ef..45a1858 100644 --- a/backend/src/utils/read/models.py +++ b/backend/src/utils/read/models.py @@ -38,6 +38,8 @@ class ChatListItem(BaseModel): last_date: datetime | None last_text: str | None last_sender_id: int | None + last_message_id: int | None = None + last_deleted: bool = False class DiscoverItem(BaseModel): @@ -204,6 +206,7 @@ class MessageView(BaseModel): is_animated_emoji: bool = False read: bool = False read_at: datetime | None = None + has_note: bool = False class MessageVersionView(BaseModel): @@ -611,6 +614,31 @@ class AnnotationView(BaseModel): updated_at: datetime +class NoteView(AnnotationView): + chat_title: str | None + chat_has_avatar: bool + message_text: str | None + message_date: datetime | None + message_has_media: bool + message_deleted: bool + + +class NoteTag(BaseModel): + tag: str + count: int + + +class CaptureStatus(BaseModel): + live: bool + since: datetime | None + last_seen: datetime | None + + +class CaptureGap(BaseModel): + start: datetime + end: datetime + + class WatchView(BaseModel): id: int account_id: int