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(),
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.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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,9 +8,12 @@ export interface ChatNotificationSettings {
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", {
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<void> {
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> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
}

View File

@@ -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;

View File

@@ -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<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [messageResults, setMessageResults] = useState<Message[]>([]);
const [archivedChats, setArchivedChats] = useState<typeof chats>([]);
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 [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(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 (
<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">
@@ -238,7 +267,7 @@ export function ChatList() {
) : null}
</div>
<div className="tg-scrollbar flex-1 overflow-auto">
{filteredChats.map((chat) => (
{visibleChats.map((chat) => (
<button
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"
@@ -291,6 +320,27 @@ export function ChatList() {
>
{chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"}
</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>,
document.body
)