feat(chats): add per-user chat archive support

This commit is contained in:
2026-03-08 09:53:28 +03:00
parent 76f008d635
commit fdf973eeab
10 changed files with 248 additions and 16 deletions

View File

@@ -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")

View File

@@ -75,3 +75,19 @@ class ChatNotificationSetting(Base):
onupdate=func.now(), onupdate=func.now(),
nullable=False, 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,
)

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession 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 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]]: 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(): if query and query.strip():
q = f"%{query.strip()}%" q = f"%{query.strip()}%"
stmt = stmt.where( stmt = stmt.where(
@@ -80,6 +88,30 @@ async def list_user_chats(
return list(result.scalars().all()) 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: async def get_chat_by_id(db: AsyncSession, chat_id: int) -> Chat | None:
result = await db.execute(select(Chat).where(Chat.id == chat_id)) result = await db.execute(select(Chat).where(Chat.id == chat_id))
return result.scalar_one_or_none() 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: 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) setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id)
return bool(setting and setting.muted) 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

View File

@@ -32,6 +32,7 @@ from app.chats.service import (
remove_chat_member_for_user, remove_chat_member_for_user,
serialize_chat_for_user, serialize_chat_for_user,
serialize_chats_for_user, serialize_chats_for_user,
set_chat_archived_for_user,
update_chat_member_role_for_user, update_chat_member_role_for_user,
update_chat_notification_settings_for_user, update_chat_notification_settings_for_user,
update_chat_title_for_user, update_chat_title_for_user,
@@ -47,10 +48,18 @@ async def list_chats(
limit: int = 50, limit: int = 50,
before_id: int | None = None, before_id: int | None = None,
query: str | None = None, query: str | None = None,
archived: bool = False,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> list[ChatRead]: ) -> 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) 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, user_id=current_user.id,
payload=payload, 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)

View File

@@ -17,6 +17,7 @@ class ChatRead(BaseModel):
description: str | None = None description: str | None = None
is_public: bool = False is_public: bool = False
is_saved: bool = False is_saved: bool = False
archived: bool = False
unread_count: int = 0 unread_count: int = 0
pinned_message_id: int | None = None pinned_message_id: int | None = None
members_count: int | None = None members_count: int | None = None

View File

@@ -62,6 +62,8 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
subscribers_count = members_count subscribers_count = members_count
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id) 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( return ChatRead.model_validate(
{ {
@@ -74,6 +76,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
"description": chat.description, "description": chat.description,
"is_public": chat.is_public, "is_public": chat.is_public,
"is_saved": chat.is_saved, "is_saved": chat.is_saved,
"archived": archived,
"unread_count": unread_count, "unread_count": unread_count,
"pinned_message_id": chat.pinned_message_id, "pinned_message_id": chat.pinned_message_id,
"members_count": members_count, "members_count": members_count,
@@ -167,14 +170,18 @@ async def get_chats_for_user(
limit: int = 50, limit: int = 50,
before_id: int | None = None, before_id: int | None = None,
query: str | None = None, query: str | None = None,
archived: bool = False,
) -> list[Chat]: ) -> list[Chat]:
safe_limit = max(1, min(limit, 100)) 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) if archived:
saved = await ensure_saved_messages_chat(db, user_id=user_id) chats = await repository.list_archived_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id)
if saved.id not in [c.id for c in chats]:
chats = [saved, *chats]
else: 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 return chats
@@ -480,3 +487,17 @@ async def update_chat_notification_settings_for_user(
user_id=user_id, user_id=user_id,
muted=setting.muted, 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

View File

@@ -1,5 +1,5 @@
from app.auth.models import EmailVerificationToken, PasswordResetToken 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.email.models import EmailLog
from app.media.models import Attachment from app.media.models import Attachment
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt
@@ -10,6 +10,7 @@ __all__ = [
"Attachment", "Attachment",
"Chat", "Chat",
"ChatMember", "ChatMember",
"ChatUserSetting",
"EmailLog", "EmailLog",
"EmailVerificationToken", "EmailVerificationToken",
"Message", "Message",

View File

@@ -8,9 +8,12 @@ export interface ChatNotificationSettings {
muted: boolean; muted: boolean;
} }
export async function getChats(query?: string): Promise<Chat[]> { export async function getChats(query?: string, archived = false): Promise<Chat[]> {
const { data } = await http.get<Chat[]>("/chats", { const { data } = await http.get<Chat[]>("/chats", {
params: query?.trim() ? { query: query.trim() } : undefined params: {
...(query?.trim() ? { query: query.trim() } : {}),
...(archived ? { archived: true } : {})
}
}); });
return data; return data;
} }
@@ -185,6 +188,16 @@ export async function clearChat(chatId: number): Promise<void> {
await http.post(`/chats/${chatId}/clear`); await http.post(`/chats/${chatId}/clear`);
} }
export async function archiveChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/archive`);
return data;
}
export async function unarchiveChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/unarchive`);
return data;
}
export async function deleteMessage(messageId: number, forAll = false): Promise<void> { export async function deleteMessage(messageId: number, forAll = false): Promise<void> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } }); await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
} }

View File

@@ -12,6 +12,7 @@ export interface Chat {
description?: string | null; description?: string | null;
is_public?: boolean; is_public?: boolean;
is_saved?: boolean; is_saved?: boolean;
archived?: boolean;
unread_count?: number; unread_count?: number;
pinned_message_id?: number | null; pinned_message_id?: number | null;
members_count?: number | null; members_count?: number | null;

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; 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 { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { updateMyProfile } from "../api/users"; import { updateMyProfile } from "../api/users";
@@ -20,8 +20,9 @@ export function ChatList() {
const [userResults, setUserResults] = useState<UserSearchItem[]>([]); const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]); const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [messageResults, setMessageResults] = useState<Message[]>([]); const [messageResults, setMessageResults] = useState<Message[]>([]);
const [archivedChats, setArchivedChats] = useState<typeof chats>([]);
const [searchLoading, setSearchLoading] = useState(false); 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<number | null>(null); const [ctxChatId, setCtxChatId] = useState<number | null>(null);
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
@@ -45,6 +46,28 @@ export function ChatList() {
void loadChats(); void loadChats();
}, [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(() => { useEffect(() => {
const term = search.trim(); const term = search.trim();
if (term.replace("@", "").length < 2) { if (term.replace("@", "").length < 2) {
@@ -108,6 +131,9 @@ export function ChatList() {
}, [me]); }, [me]);
const filteredChats = chats.filter((chat) => { const filteredChats = chats.filter((chat) => {
if (chat.archived) {
return false;
}
if (tab === "people") { if (tab === "people") {
return chat.type === "private"; return chat.type === "private";
} }
@@ -120,13 +146,16 @@ export function ChatList() {
return true; 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: "all", label: "All" },
{ id: "people", label: "Люди" }, { id: "people", label: "Люди" },
{ id: "groups", label: "Groups" }, { id: "groups", label: "Groups" },
{ id: "channels", label: "Каналы" } { id: "channels", label: "Каналы" },
{ id: "archived", label: "Архив" }
]; ];
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
return ( return (
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}> <aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
<div className="border-b border-slate-700/50 px-3 py-3"> <div className="border-b border-slate-700/50 px-3 py-3">
@@ -238,7 +267,7 @@ export function ChatList() {
) : null} ) : null}
</div> </div>
<div className="tg-scrollbar flex-1 overflow-auto"> <div className="tg-scrollbar flex-1 overflow-auto">
{filteredChats.map((chat) => ( {visibleChats.map((chat) => (
<button <button
className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${ className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${
activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65" activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65"
@@ -291,6 +320,27 @@ export function ChatList() {
> >
{chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"} {chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"}
</button> </button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
if (!target) {
return;
}
if (target.archived) {
await unarchiveChat(target.id);
} else {
await archiveChat(target.id);
}
await loadChats();
const nextArchived = await getChats(undefined, true);
setArchivedChats(nextArchived);
setCtxChatId(null);
setCtxPos(null);
}}
>
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"}
</button>
</div>, </div>,
document.body document.body
) )