feat(backend,frontend): feed gets a bots tab so private is people only, a folder's context menu opens the feed of its chats

This commit is contained in:
hh committed 2026-10-26 01:50:44 +00:00
1 parent b9e8a96774
commit c2180e80ad
11 files changed
+128 -16

No files matched your search

+5 -4
View File
@@ -298,10 +298,11 @@ async def get_feed(
"""Deleted and edited messages and profile changes across chats, newest first. """Deleted and edited messages and profile changes across chats, newest first.
`kind` picks deleted, edited, profile, messages (deleted and edited) or all; `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, `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` with people, bot, group, channel) narrow it down. Edited items carry
texts. A deleted item with `message_count` above 1 is a bulk deletion in `before` and `after` texts. A deleted item with `message_count` above 1 is
one chat; read its messages with `get_deleted_messages` for that chat. 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 Profile items cover people in private chats or contacts and chats in the
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
`title` to `[old, new]`, changes a day apart or closer are merged. Pass `title` to `[old, new]`, changes a day apart or closer are merged. Pass
+8 -1
View File
@@ -29,6 +29,7 @@ async def list_feed(
kind: Annotated[FeedKind, Query()] = FeedKind.ALL, kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None, chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None, chat_kind: Annotated[FeedChatKind | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None, since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None, cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT, limit: Limit = DEFAULT_LIMIT,
@@ -36,7 +37,13 @@ async def list_feed(
return await feed.list_feed( return await feed.list_feed(
pool, pool,
account_id, 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, limit=limit,
cursor=cursor, cursor=cursor,
) )
+41 -4
View File
@@ -5,6 +5,7 @@ from enum import StrEnum
import asyncpg import asyncpg
from utils.policy import repository as policy_repository
from utils.read import chats, peers from utils.read import chats, peers
from utils.read.accounts import self_user_id from utils.read.accounts import self_user_id
from utils.read.models import ( from utils.read.models import (
@@ -45,6 +46,13 @@ LEFT JOIN LATERAL (
) p ON true ) 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 = """ _UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted, SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited count(*) FILTER (WHERE kind = 'edited') AS edited
@@ -69,15 +77,25 @@ class FeedKind(StrEnum):
class FeedChatKind(StrEnum): class FeedChatKind(StrEnum):
DM = "dm" DM = "dm"
BOT = "bot"
GROUP = "group" GROUP = "group"
CHANNEL = "channel" 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) @dataclass(slots=True)
class FeedFilter: class FeedFilter:
kind: FeedKind = FeedKind.ALL kind: FeedKind = FeedKind.ALL
chat_id: int | None = None chat_id: int | None = None
chat_kind: FeedChatKind | None = None chat_kind: FeedChatKind | None = None
folder_id: int | None = None
since: datetime | 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) 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( 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]]: ) -> tuple[str, list[object]]:
params: list[object] = [account_id] params: list[object] = [account_id]
clauses = ["account_id = $1"] clauses = ["account_id = $1"]
if feed.kind is FeedKind.MESSAGES: if feed.kind is FeedKind.MESSAGES:
clauses.append("kind <> 'profile'") 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} exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
conditions = ( conditions = (
("kind = {}", feed.kind.value if exact else None), ("kind = {}", feed.kind.value if exact else None),
("chat_id = {}", feed.chat_id), ("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), ("at >= {}", feed.since),
) )
for template, value in conditions: for template, value in conditions:
@@ -191,7 +225,9 @@ async def _build_items(
chat=FeedChat( chat=FeedChat(
chat_id=row["chat_id"], chat_id=row["chat_id"],
title=chat.title if chat else None, 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, has_avatar=chat.has_avatar if chat else False,
), ),
message_id=row["message_id"], message_id=row["message_id"],
@@ -218,7 +254,8 @@ async def list_feed(
cursor: str | None = None, cursor: str | None = None,
) -> FeedPage: ) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT)) 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) params.append(size + 1)
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608 f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
+1
View File
@@ -284,6 +284,7 @@ export interface FeedQuery {
chat_id?: number; chat_id?: number;
chat_kind?: FeedChatKind; chat_kind?: FeedChatKind;
cursor?: string; cursor?: string;
folder_id?: number;
kind?: FeedKind; kind?: FeedKind;
limit?: number; limit?: number;
} }
+1 -1
View File
@@ -245,7 +245,7 @@ export interface MessageVersion {
} }
export type FeedKind = "all" | "deleted" | "edited" | "profile"; export type FeedKind = "all" | "deleted" | "edited" | "profile";
export type FeedChatKind = "dm" | "group" | "channel"; export type FeedChatKind = "dm" | "bot" | "group" | "channel";
export interface FeedChat { export interface FeedChat {
chat_id: number; chat_id: number;
@@ -2,8 +2,10 @@
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; 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 { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { page } from "$app/state";
interface Tab { interface Tab {
id: number | null; id: number | null;
@@ -93,6 +95,12 @@
</button> </button>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem
icon="recent"
onselect={() => feed.open(page.url, tab.id)}
>
{t("feed.folder")}
</ContextMenuItem>
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}> <ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
{t("settings.policy")} {t("settings.policy")}
</ContextMenuItem> </ContextMenuItem>
@@ -1,16 +1,32 @@
<script lang="ts"> <script lang="ts">
import type { FeedChatKind, FeedKind } from "#lib/api/types.js"; import type { FeedChatKind, FeedKind } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js"; import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js";
interface Props { interface Props {
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folder?: number | null;
kind: FeedKind; kind: FeedKind;
onchats?: (chats: FeedChatKind | null) => void; onchats?: (chats: FeedChatKind | null) => void;
onfolder?: (folder: number | null) => void;
onkind: (kind: FeedKind) => void; onkind: (kind: FeedKind) => void;
} }
let { kind, chats = null, onkind, onchats }: Props = $props(); let {
kind,
chats = null,
folder = null,
onkind,
onchats,
onfolder,
}: Props = $props();
const folderTitle = $derived(
folders.list.find((item) => item.folder_id === folder)?.title ??
t("feed.folder")
);
const KIND_KEYS = { const KIND_KEYS = {
all: "feed.filter.all", all: "feed.filter.all",
@@ -20,6 +36,7 @@
} as const; } as const;
const CHAT_KEYS = { const CHAT_KEYS = {
bot: "feed.chats.bot",
channel: "feed.chats.channel", channel: "feed.chats.channel",
dm: "feed.chats.dm", dm: "feed.chats.dm",
group: "feed.chats.group", group: "feed.chats.group",
@@ -55,6 +72,20 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if folder !== null && onfolder}
<div class="group">
<button
aria-pressed="true"
class="chip on"
onclick={() => onfolder(null)}
type="button"
>
<Icon name="folder" size="0.875rem" />
{folderTitle}
<Icon name="close" size="0.875rem" />
</button>
</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -78,6 +109,7 @@
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
gap: 0.25rem;
align-items: center; align-items: center;
height: 1.75rem; height: 1.75rem;
@@ -14,11 +14,12 @@
interface Props { interface Props {
chatId?: number; chatId?: number;
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folderId?: number | null;
kind: FeedKind; kind: FeedKind;
onopen: (item: FeedItem, messageId: number) => void; onopen: (item: FeedItem, messageId: number) => void;
} }
let { kind, chats = null, chatId, onopen }: Props = $props(); let { kind, chats = null, folderId = null, chatId, onopen }: Props = $props();
const PAGE = 30; const PAGE = 30;
const EMPTY_KEYS = { const EMPTY_KEYS = {
@@ -38,6 +39,7 @@
const filter = $derived<FeedQuery>({ const filter = $derived<FeedQuery>({
chat_id: chatId, chat_id: chatId,
chat_kind: chats ?? undefined, chat_kind: chats ?? undefined,
folder_id: folderId ?? undefined,
kind, kind,
limit: PAGE, limit: PAGE,
}); });
@@ -55,12 +55,19 @@
</header> </header>
<FeedFilters <FeedFilters
chats={filter.chats} chats={filter.chats}
folder={filter.folder}
kind={filter.kind} kind={filter.kind}
onchats={(chats) => feed.filter(page.url, { ...filter, chats })} onchats={(chats) => feed.filter(page.url, { ...filter, chats })}
onfolder={(folder) => feed.filter(page.url, { ...filter, folder })}
onkind={(kind) => feed.filter(page.url, { ...filter, kind })} onkind={(kind) => feed.filter(page.url, { ...filter, kind })}
/> />
<div class="feed-body custom-scroll"> <div class="feed-body custom-scroll">
<FeedList chats={filter.chats} kind={filter.kind} onopen={open} /> <FeedList
chats={filter.chats}
folderId={filter.folder}
kind={filter.kind}
onopen={open}
/>
</div> </div>
</div> </div>
+2
View File
@@ -59,6 +59,7 @@ export const feed = {
}, },
}, },
"feed.changes": { en: "Changes", ru: "Изменения" }, "feed.changes": { en: "Changes", ru: "Изменения" },
"feed.chats.bot": { en: "Bots", ru: "Боты" },
"feed.chats.channel": { en: "Channels", ru: "Каналы" }, "feed.chats.channel": { en: "Channels", ru: "Каналы" },
"feed.chats.dm": { en: "Private", ru: "Личка" }, "feed.chats.dm": { en: "Private", ru: "Личка" },
"feed.chats.group": { en: "Groups", ru: "Группы" }, "feed.chats.group": { en: "Groups", ru: "Группы" },
@@ -92,6 +93,7 @@ export const feed = {
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" }, "feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
"feed.filter.edited": { en: "Edited", ru: "Изменённые" }, "feed.filter.edited": { en: "Edited", ru: "Изменённые" },
"feed.filter.profile": { en: "Profiles", ru: "Профили" }, "feed.filter.profile": { en: "Profiles", ru: "Профили" },
"feed.folder": { en: "Folder feed", ru: "Лента папки" },
"feed.lostText": { "feed.lostText": {
en: "The earlier text wasn't saved", en: "The earlier text wasn't saved",
ru: "Прежний текст не сохранился", ru: "Прежний текст не сохранился",
+18 -3
View File
@@ -8,14 +8,21 @@ import { goto } from "$app/navigation";
const LIVE_DELAY = 600; const LIVE_DELAY = 600;
const KIND_PARAM = "feed"; const KIND_PARAM = "feed";
const CHATS_PARAM = "chats"; const CHATS_PARAM = "chats";
const FOLDER_PARAM = "folder";
const MESSAGE_PARAM = "message"; const MESSAGE_PARAM = "message";
export const MARK_PARAM = "mark"; export const MARK_PARAM = "mark";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"]; export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"]; export const FEED_CHAT_KINDS: FeedChatKind[] = [
"dm",
"bot",
"group",
"channel",
];
export interface FeedFilter { export interface FeedFilter {
chats: FeedChatKind | null; chats: FeedChatKind | null;
folder: number | null;
kind: FeedKind; kind: FeedKind;
} }
@@ -30,10 +37,12 @@ export function feedFilterOf(url: UrlLike): FeedFilter | null {
return null; return null;
} }
const chats = searchParams.get(CHATS_PARAM); const chats = searchParams.get(CHATS_PARAM);
const folder = Number(searchParams.get(FOLDER_PARAM));
return { return {
chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind) chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind)
? (chats as FeedChatKind) ? (chats as FeedChatKind)
: null, : null,
folder: Number.isInteger(folder) && folder > 0 ? folder : null,
kind: kind as FeedKind, kind: kind as FeedKind,
}; };
} }
@@ -42,12 +51,16 @@ function withFilter(url: UrlLike, filter: FeedFilter | null): URL {
const next = new URL(url.href); const next = new URL(url.href);
next.searchParams.delete(KIND_PARAM); next.searchParams.delete(KIND_PARAM);
next.searchParams.delete(CHATS_PARAM); next.searchParams.delete(CHATS_PARAM);
next.searchParams.delete(FOLDER_PARAM);
next.searchParams.delete(MESSAGE_PARAM); next.searchParams.delete(MESSAGE_PARAM);
if (filter) { if (filter) {
next.searchParams.set(KIND_PARAM, filter.kind); next.searchParams.set(KIND_PARAM, filter.kind);
if (filter.chats) { if (filter.chats) {
next.searchParams.set(CHATS_PARAM, filter.chats); next.searchParams.set(CHATS_PARAM, filter.chats);
} }
if (filter.folder !== null) {
next.searchParams.set(FOLDER_PARAM, String(filter.folder));
}
} }
return next; return next;
} }
@@ -140,8 +153,10 @@ function createFeed() {
unseen = next; unseen = next;
} }
}, },
async open(url: UrlLike) { async open(url: UrlLike, folder: number | null = null) {
const target = href(withFilter(url, { chats: null, kind: "all" })); const target = href(
withFilter(url, { chats: null, folder, kind: "all" })
);
openedHref = target; openedHref = target;
await goto(target, { reset: false }); await goto(target, { reset: false });
}, },