From c2180e80ad2355657d6122264f050500d7be5dc3 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 26 Oct 2026 01:50:43 +0000 Subject: [PATCH] feat(backend,frontend): feed gets a bots tab so private is people only, a folder's context menu opens the feed of its chats --- backend/src/api/mcp/server.py | 9 ++-- backend/src/api/routers/feed.py | 9 +++- backend/src/utils/read/feed.py | 45 +++++++++++++++++-- frontend/src/lib/api/endpoints.ts | 1 + frontend/src/lib/api/types.ts | 2 +- frontend/src/lib/components/FolderTabs.svelte | 8 ++++ .../lib/components/feed/FeedFilters.svelte | 34 +++++++++++++- .../src/lib/components/feed/FeedList.svelte | 4 +- .../src/lib/components/feed/FeedView.svelte | 9 +++- frontend/src/lib/i18n/messages/feed.ts | 2 + frontend/src/lib/stores/feed.svelte.ts | 21 +++++++-- 11 files changed, 128 insertions(+), 16 deletions(-) diff --git a/backend/src/api/mcp/server.py b/backend/src/api/mcp/server.py index 31eec40..5ab13c6 100644 --- a/backend/src/api/mcp/server.py +++ b/backend/src/api/mcp/server.py @@ -298,10 +298,11 @@ async def get_feed( """Deleted and edited messages and profile changes across chats, newest first. `kind` picks deleted, edited, profile, messages (deleted and edited) or all; - `since` keeps items at or after that time; `chat_id` or `chat_kind` (dm, - group, channel) narrow it down. Edited items carry `before` and `after` - texts. A deleted item with `message_count` above 1 is a bulk deletion in - one chat; read its messages with `get_deleted_messages` for that chat. + `since` keeps items at or after that time; `chat_id` or `chat_kind` (dm + with people, bot, group, channel) narrow it down. Edited items carry + `before` and `after` texts. A deleted item with `message_count` above 1 is + a bulk deletion in one chat; read its messages with `get_deleted_messages` + for that chat. Profile items cover people in private chats or contacts and chats in the dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or `title` to `[old, new]`, changes a day apart or closer are merged. Pass diff --git a/backend/src/api/routers/feed.py b/backend/src/api/routers/feed.py index c1e514e..d906abf 100644 --- a/backend/src/api/routers/feed.py +++ b/backend/src/api/routers/feed.py @@ -29,6 +29,7 @@ async def list_feed( kind: Annotated[FeedKind, Query()] = FeedKind.ALL, chat_id: Annotated[int | None, Query()] = None, chat_kind: Annotated[FeedChatKind | None, Query()] = None, + folder_id: Annotated[int | None, Query()] = None, since: Annotated[datetime | None, Query()] = None, cursor: Cursor = None, limit: Limit = DEFAULT_LIMIT, @@ -36,7 +37,13 @@ async def list_feed( return await feed.list_feed( pool, account_id, - FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since), + FeedFilter( + kind=kind, + chat_id=chat_id, + chat_kind=chat_kind, + folder_id=folder_id, + since=since, + ), limit=limit, cursor=cursor, ) diff --git a/backend/src/utils/read/feed.py b/backend/src/utils/read/feed.py index 96cf01b..279c7f6 100644 --- a/backend/src/utils/read/feed.py +++ b/backend/src/utils/read/feed.py @@ -5,6 +5,7 @@ from enum import StrEnum import asyncpg +from utils.policy import repository as policy_repository from utils.read import chats, peers from utils.read.accounts import self_user_id from utils.read.models import ( @@ -45,6 +46,13 @@ LEFT JOIN LATERAL ( ) p ON true """ +_IS_BOT = """EXISTS ( + SELECT 1 FROM peers p + WHERE p.account_id = feed_items.account_id AND p.peer_id = feed_items.chat_id + AND COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, + p.raw->>'type' = 'ChatType.BOT', false) +)""" + _UNSEEN = """ SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted, count(*) FILTER (WHERE kind = 'edited') AS edited @@ -69,15 +77,25 @@ class FeedKind(StrEnum): class FeedChatKind(StrEnum): DM = "dm" + BOT = "bot" GROUP = "group" CHANNEL = "channel" +_CHAT_KIND_CLAUSES = { + FeedChatKind.DM: f"chat_kind = 'dm' AND NOT {_IS_BOT}", + FeedChatKind.BOT: f"chat_kind = 'dm' AND {_IS_BOT}", + FeedChatKind.GROUP: "chat_kind = 'group'", + FeedChatKind.CHANNEL: "chat_kind = 'channel'", +} + + @dataclass(slots=True) class FeedFilter: kind: FeedKind = FeedKind.ALL chat_id: int | None = None chat_kind: FeedChatKind | None = None + folder_id: int | None = None since: datetime | None = None @@ -90,18 +108,34 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]: return _EPOCH + int(micros) * _MICROSECOND, int(key) +async def _folder_chat_ids( + pool: asyncpg.Pool, account_id: int, folder_id: int | None +) -> list[int] | None: + if folder_id is None: + return None + folder = await policy_repository.get_folder(pool, account_id, folder_id) + if folder is None: + return [] + return await chats.folder_chat_ids(pool, account_id, folder) + + def _where( - account_id: int, feed: FeedFilter, cursor: str | None + account_id: int, + feed: FeedFilter, + folder_chat_ids: list[int] | None, + cursor: str | None, ) -> tuple[str, list[object]]: params: list[object] = [account_id] clauses = ["account_id = $1"] if feed.kind is FeedKind.MESSAGES: clauses.append("kind <> 'profile'") + if feed.chat_kind is not None: + clauses.append(_CHAT_KIND_CLAUSES[feed.chat_kind]) exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES} conditions = ( ("kind = {}", feed.kind.value if exact else None), ("chat_id = {}", feed.chat_id), - ("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None), + ("chat_id = ANY({}::bigint[])", folder_chat_ids), ("at >= {}", feed.since), ) for template, value in conditions: @@ -191,7 +225,9 @@ async def _build_items( chat=FeedChat( chat_id=row["chat_id"], title=chat.title if chat else None, - kind=row["chat_kind"], + kind=FeedChatKind.BOT + if chat and chat.is_bot and row["chat_kind"] == FeedChatKind.DM + else row["chat_kind"], has_avatar=chat.has_avatar if chat else False, ), message_id=row["message_id"], @@ -218,7 +254,8 @@ async def list_feed( cursor: str | None = None, ) -> FeedPage: size = max(1, min(limit, MAX_LIMIT)) - where, params = _where(account_id, feed, cursor) + folder_chat_ids = await _folder_chat_ids(pool, account_id, feed.folder_id) + where, params = _where(account_id, feed, folder_chat_ids, cursor) params.append(size + 1) rows = await pool.fetch( f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608 diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index ce0858b..5bc42e2 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -284,6 +284,7 @@ export interface FeedQuery { chat_id?: number; chat_kind?: FeedChatKind; cursor?: string; + folder_id?: number; kind?: FeedKind; limit?: number; } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 771a434..134dc54 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -245,7 +245,7 @@ export interface MessageVersion { } export type FeedKind = "all" | "deleted" | "edited" | "profile"; -export type FeedChatKind = "dm" | "group" | "channel"; +export type FeedChatKind = "dm" | "bot" | "group" | "channel"; export interface FeedChat { chat_id: number; diff --git a/frontend/src/lib/components/FolderTabs.svelte b/frontend/src/lib/components/FolderTabs.svelte index 8fe740c..4686d9d 100644 --- a/frontend/src/lib/components/FolderTabs.svelte +++ b/frontend/src/lib/components/FolderTabs.svelte @@ -2,8 +2,10 @@ import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import { t } from "#lib/i18n/locale.svelte.js"; + import { feed } from "#lib/stores/feed.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js"; + import { page } from "$app/state"; interface Tab { id: number | null; @@ -93,6 +95,12 @@ {/snippet} {#snippet menu()} + feed.open(page.url, tab.id)} + > + {t("feed.folder")} + editPolicy(tab)}> {t("settings.policy")} diff --git a/frontend/src/lib/components/feed/FeedFilters.svelte b/frontend/src/lib/components/feed/FeedFilters.svelte index c91193c..17c8390 100644 --- a/frontend/src/lib/components/feed/FeedFilters.svelte +++ b/frontend/src/lib/components/feed/FeedFilters.svelte @@ -1,16 +1,32 @@