From fdf973eeab77e0e6d0645161118553d7d75d02e9 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 09:53:28 +0300 Subject: [PATCH] feat(chats): add per-user chat archive support --- alembic/versions/0014_chat_user_settings.py | 43 +++++++++++++++ app/chats/models.py | 16 ++++++ app/chats/repository.py | 61 ++++++++++++++++++++- app/chats/router.py | 31 ++++++++++- app/chats/schemas.py | 1 + app/chats/service.py | 31 +++++++++-- app/database/models.py | 3 +- web/src/api/chats.ts | 17 +++++- web/src/chat/types.ts | 1 + web/src/components/ChatList.tsx | 60 ++++++++++++++++++-- 10 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 alembic/versions/0014_chat_user_settings.py diff --git a/alembic/versions/0014_chat_user_settings.py b/alembic/versions/0014_chat_user_settings.py new file mode 100644 index 0000000..9e44835 --- /dev/null +++ b/alembic/versions/0014_chat_user_settings.py @@ -0,0 +1,43 @@ +"""add chat user settings for archive + +Revision ID: 0014_chat_user_set +Revises: 0013_msg_reactions +Create Date: 2026-03-08 19:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0014_chat_user_set" +down_revision: Union[str, Sequence[str], None] = "0013_msg_reactions" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "chat_user_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("chat_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_user_settings_chat_id_chats"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_chat_user_settings_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_user_settings")), + sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_user_settings_chat_user"), + ) + op.create_index(op.f("ix_chat_user_settings_id"), "chat_user_settings", ["id"], unique=False) + op.create_index(op.f("ix_chat_user_settings_chat_id"), "chat_user_settings", ["chat_id"], unique=False) + op.create_index(op.f("ix_chat_user_settings_user_id"), "chat_user_settings", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_chat_user_settings_user_id"), table_name="chat_user_settings") + op.drop_index(op.f("ix_chat_user_settings_chat_id"), table_name="chat_user_settings") + op.drop_index(op.f("ix_chat_user_settings_id"), table_name="chat_user_settings") + op.drop_table("chat_user_settings") + diff --git a/app/chats/models.py b/app/chats/models.py index 40c54ce..1fcd81e 100644 --- a/app/chats/models.py +++ b/app/chats/models.py @@ -75,3 +75,19 @@ class ChatNotificationSetting(Base): onupdate=func.now(), nullable=False, ) + + +class ChatUserSetting(Base): + __tablename__ = "chat_user_settings" + __table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_chat_user_settings_chat_user"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/app/chats/repository.py b/app/chats/repository.py index 789b935..1dae406 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession -from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType +from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting from app.messages.models import Message, MessageHidden, MessageReceipt @@ -53,7 +53,15 @@ async def count_chat_members(db: AsyncSession, *, chat_id: int) -> int: def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Chat]]: - stmt = select(Chat).join(ChatMember, ChatMember.chat_id == Chat.id).where(ChatMember.user_id == user_id) + stmt = ( + select(Chat) + .join(ChatMember, ChatMember.chat_id == Chat.id) + .outerjoin( + ChatUserSetting, + (ChatUserSetting.chat_id == Chat.id) & (ChatUserSetting.user_id == user_id), + ) + .where(ChatMember.user_id == user_id, func.coalesce(ChatUserSetting.archived, False).is_(False)) + ) if query and query.strip(): q = f"%{query.strip()}%" stmt = stmt.where( @@ -80,6 +88,30 @@ async def list_user_chats( return list(result.scalars().all()) +async def list_archived_user_chats( + db: AsyncSession, + *, + user_id: int, + limit: int = 50, + before_id: int | None = None, +) -> list[Chat]: + stmt = ( + select(Chat) + .join(ChatMember, ChatMember.chat_id == Chat.id) + .join( + ChatUserSetting, + (ChatUserSetting.chat_id == Chat.id) & (ChatUserSetting.user_id == user_id), + ) + .where(ChatMember.user_id == user_id, ChatUserSetting.archived.is_(True)) + .order_by(Chat.id.desc()) + .limit(limit) + ) + if before_id is not None: + stmt = stmt.where(Chat.id < before_id) + result = await db.execute(stmt) + return list(result.scalars().all()) + + async def get_chat_by_id(db: AsyncSession, chat_id: int) -> Chat | None: result = await db.execute(select(Chat).where(Chat.id == chat_id)) return result.scalar_one_or_none() @@ -230,3 +262,28 @@ async def upsert_chat_notification_setting( async def is_chat_muted_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> bool: setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id) return bool(setting and setting.muted) + + +async def get_chat_user_setting(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatUserSetting | None: + result = await db.execute( + select(ChatUserSetting).where(ChatUserSetting.chat_id == chat_id, ChatUserSetting.user_id == user_id) + ) + return result.scalar_one_or_none() + + +async def upsert_chat_archived_setting( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + archived: bool, +) -> ChatUserSetting: + setting = await get_chat_user_setting(db, chat_id=chat_id, user_id=user_id) + if setting: + setting.archived = archived + await db.flush() + return setting + setting = ChatUserSetting(chat_id=chat_id, user_id=user_id, archived=archived) + db.add(setting) + await db.flush() + return setting diff --git a/app/chats/router.py b/app/chats/router.py index 6e660c4..01d4689 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -32,6 +32,7 @@ from app.chats.service import ( remove_chat_member_for_user, serialize_chat_for_user, serialize_chats_for_user, + set_chat_archived_for_user, update_chat_member_role_for_user, update_chat_notification_settings_for_user, update_chat_title_for_user, @@ -47,10 +48,18 @@ async def list_chats( limit: int = 50, before_id: int | None = None, query: str | None = None, + archived: bool = False, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> list[ChatRead]: - chats = await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query) + chats = await get_chats_for_user( + db, + user_id=current_user.id, + limit=limit, + before_id=before_id, + query=query, + archived=archived, + ) return await serialize_chats_for_user(db, user_id=current_user.id, chats=chats) @@ -228,3 +237,23 @@ async def update_chat_notifications( user_id=current_user.id, payload=payload, ) + + +@router.post("/{chat_id}/archive", response_model=ChatRead) +async def archive_chat( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatRead: + chat = await set_chat_archived_for_user(db, chat_id=chat_id, user_id=current_user.id, archived=True) + return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) + + +@router.post("/{chat_id}/unarchive", response_model=ChatRead) +async def unarchive_chat( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatRead: + chat = await set_chat_archived_for_user(db, chat_id=chat_id, user_id=current_user.id, archived=False) + return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) diff --git a/app/chats/schemas.py b/app/chats/schemas.py index 287a67e..a9743c9 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -17,6 +17,7 @@ class ChatRead(BaseModel): description: str | None = None is_public: bool = False is_saved: bool = False + archived: bool = False unread_count: int = 0 pinned_message_id: int | None = None members_count: int | None = None diff --git a/app/chats/service.py b/app/chats/service.py index bdcf2c0..5d320f4 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -62,6 +62,8 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) subscribers_count = members_count unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id) + user_setting = await repository.get_chat_user_setting(db, chat_id=chat.id, user_id=user_id) + archived = bool(user_setting and user_setting.archived) return ChatRead.model_validate( { @@ -74,6 +76,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) "description": chat.description, "is_public": chat.is_public, "is_saved": chat.is_saved, + "archived": archived, "unread_count": unread_count, "pinned_message_id": chat.pinned_message_id, "members_count": members_count, @@ -167,14 +170,18 @@ async def get_chats_for_user( limit: int = 50, before_id: int | None = None, query: str | None = None, + archived: bool = False, ) -> list[Chat]: safe_limit = max(1, min(limit, 100)) - chats = await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query) - saved = await ensure_saved_messages_chat(db, user_id=user_id) - if saved.id not in [c.id for c in chats]: - chats = [saved, *chats] + if archived: + chats = await repository.list_archived_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id) else: - chats = [saved, *[c for c in chats if c.id != saved.id]] + chats = await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query) + saved = await ensure_saved_messages_chat(db, user_id=user_id) + if saved.id not in [c.id for c in chats]: + chats = [saved, *chats] + else: + chats = [saved, *[c for c in chats if c.id != saved.id]] return chats @@ -480,3 +487,17 @@ async def update_chat_notification_settings_for_user( user_id=user_id, muted=setting.muted, ) + + +async def set_chat_archived_for_user( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + archived: bool, +) -> Chat: + chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) + await repository.upsert_chat_archived_setting(db, chat_id=chat_id, user_id=user_id, archived=archived) + await db.commit() + await db.refresh(chat) + return chat diff --git a/app/database/models.py b/app/database/models.py index aa698a5..f2a1e09 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,5 +1,5 @@ from app.auth.models import EmailVerificationToken, PasswordResetToken -from app.chats.models import Chat, ChatMember +from app.chats.models import Chat, ChatMember, ChatUserSetting from app.email.models import EmailLog from app.media.models import Attachment from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt @@ -10,6 +10,7 @@ __all__ = [ "Attachment", "Chat", "ChatMember", + "ChatUserSetting", "EmailLog", "EmailVerificationToken", "Message", diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index 1ac50ec..ec882b8 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -8,9 +8,12 @@ export interface ChatNotificationSettings { muted: boolean; } -export async function getChats(query?: string): Promise { +export async function getChats(query?: string, archived = false): Promise { const { data } = await http.get("/chats", { - params: query?.trim() ? { query: query.trim() } : undefined + params: { + ...(query?.trim() ? { query: query.trim() } : {}), + ...(archived ? { archived: true } : {}) + } }); return data; } @@ -185,6 +188,16 @@ export async function clearChat(chatId: number): Promise { await http.post(`/chats/${chatId}/clear`); } +export async function archiveChat(chatId: number): Promise { + const { data } = await http.post(`/chats/${chatId}/archive`); + return data; +} + +export async function unarchiveChat(chatId: number): Promise { + const { data } = await http.post(`/chats/${chatId}/unarchive`); + return data; +} + export async function deleteMessage(messageId: number, forAll = false): Promise { await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } }); } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 0756a0c..99c2b45 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -12,6 +12,7 @@ export interface Chat { description?: string | null; is_public?: boolean; is_saved?: boolean; + archived?: boolean; unread_count?: number; pinned_message_id?: number | null; members_count?: number | null; diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index f283d7d..5847d3e 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { clearChat, createPrivateChat, deleteChat, getChats, joinChat } from "../api/chats"; +import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, joinChat, unarchiveChat } from "../api/chats"; import { globalSearch } from "../api/search"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import { updateMyProfile } from "../api/users"; @@ -20,8 +20,9 @@ export function ChatList() { const [userResults, setUserResults] = useState([]); const [discoverResults, setDiscoverResults] = useState([]); const [messageResults, setMessageResults] = useState([]); + const [archivedChats, setArchivedChats] = useState([]); const [searchLoading, setSearchLoading] = useState(false); - const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all"); + const [tab, setTab] = useState<"all" | "people" | "groups" | "channels" | "archived">("all"); const [ctxChatId, setCtxChatId] = useState(null); const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState(null); @@ -45,6 +46,28 @@ export function ChatList() { void loadChats(); }, [loadChats]); + useEffect(() => { + if (tab !== "archived") { + return; + } + let cancelled = false; + void (async () => { + try { + const rows = await getChats(undefined, true); + if (!cancelled) { + setArchivedChats(rows); + } + } catch { + if (!cancelled) { + setArchivedChats([]); + } + } + })(); + return () => { + cancelled = true; + }; + }, [tab]); + useEffect(() => { const term = search.trim(); if (term.replace("@", "").length < 2) { @@ -108,6 +131,9 @@ export function ChatList() { }, [me]); const filteredChats = chats.filter((chat) => { + if (chat.archived) { + return false; + } if (tab === "people") { return chat.type === "private"; } @@ -120,13 +146,16 @@ export function ChatList() { return true; }); - const tabs: Array<{ id: "all" | "people" | "groups" | "channels"; label: string }> = [ + const tabs: Array<{ id: "all" | "people" | "groups" | "channels" | "archived"; label: string }> = [ { id: "all", label: "All" }, { id: "people", label: "Люди" }, { id: "groups", label: "Groups" }, - { id: "channels", label: "Каналы" } + { id: "channels", label: "Каналы" }, + { id: "archived", label: "Архив" } ]; + const visibleChats = tab === "archived" ? archivedChats : filteredChats; + return (