From 8cdcd9531d5ff98edf44db7bfad10829f2078353 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 09:54:43 +0300 Subject: [PATCH] feat(chats): add per-user pinned chats and pinned sorting --- alembic/versions/0015_chat_pin_fields.py | 28 ++++++++++++++++++ app/chats/models.py | 2 ++ app/chats/repository.py | 37 ++++++++++++++++++++++-- app/chats/router.py | 21 ++++++++++++++ app/chats/schemas.py | 1 + app/chats/service.py | 16 ++++++++++ web/src/api/chats.ts | 10 +++++++ web/src/chat/types.ts | 1 + web/src/components/ChatList.tsx | 27 +++++++++++++++-- 9 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/0015_chat_pin_fields.py diff --git a/alembic/versions/0015_chat_pin_fields.py b/alembic/versions/0015_chat_pin_fields.py new file mode 100644 index 0000000..99d6674 --- /dev/null +++ b/alembic/versions/0015_chat_pin_fields.py @@ -0,0 +1,28 @@ +"""add chat pin fields to chat user settings + +Revision ID: 0015_chat_pin_set +Revises: 0014_chat_user_set +Create Date: 2026-03-08 19:20:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0015_chat_pin_set" +down_revision: Union[str, Sequence[str], None] = "0014_chat_user_set" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("chat_user_settings", sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false"))) + op.add_column("chat_user_settings", sa.Column("pinned_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("chat_user_settings", "pinned_at") + op.drop_column("chat_user_settings", "pinned") + diff --git a/app/chats/models.py b/app/chats/models.py index 1fcd81e..24a5656 100644 --- a/app/chats/models.py +++ b/app/chats/models.py @@ -85,6 +85,8 @@ class ChatUserSetting(Base): 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") + pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + pinned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/app/chats/repository.py b/app/chats/repository.py index 1dae406..65e63fc 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -70,7 +70,11 @@ def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Ch Chat.type.cast(String).ilike(q), ) ) - return stmt.order_by(Chat.id.desc()) + return stmt.order_by( + func.coalesce(ChatUserSetting.pinned, False).desc(), + ChatUserSetting.pinned_at.desc().nullslast(), + Chat.id.desc(), + ) async def list_user_chats( @@ -103,7 +107,11 @@ async def list_archived_user_chats( (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()) + .order_by( + func.coalesce(ChatUserSetting.pinned, False).desc(), + ChatUserSetting.pinned_at.desc().nullslast(), + Chat.id.desc(), + ) .limit(limit) ) if before_id is not None: @@ -287,3 +295,28 @@ async def upsert_chat_archived_setting( db.add(setting) await db.flush() return setting + + +async def upsert_chat_pinned_setting( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + pinned: bool, +) -> ChatUserSetting: + setting = await get_chat_user_setting(db, chat_id=chat_id, user_id=user_id) + if setting: + setting.pinned = pinned + setting.pinned_at = func.now() if pinned else None + await db.flush() + return setting + setting = ChatUserSetting( + chat_id=chat_id, + user_id=user_id, + archived=False, + pinned=pinned, + pinned_at=func.now() if pinned else None, + ) + db.add(setting) + await db.flush() + return setting diff --git a/app/chats/router.py b/app/chats/router.py index 01d4689..1ba601b 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -33,6 +33,7 @@ from app.chats.service import ( serialize_chat_for_user, serialize_chats_for_user, set_chat_archived_for_user, + set_chat_pinned_for_user, update_chat_member_role_for_user, update_chat_notification_settings_for_user, update_chat_title_for_user, @@ -257,3 +258,23 @@ async def unarchive_chat( ) -> 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) + + +@router.post("/{chat_id}/pin-chat", response_model=ChatRead) +async def pin_chat( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatRead: + chat = await set_chat_pinned_for_user(db, chat_id=chat_id, user_id=current_user.id, pinned=True) + return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) + + +@router.post("/{chat_id}/unpin-chat", response_model=ChatRead) +async def unpin_chat( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatRead: + chat = await set_chat_pinned_for_user(db, chat_id=chat_id, user_id=current_user.id, pinned=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 a9743c9..c11a994 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -18,6 +18,7 @@ class ChatRead(BaseModel): is_public: bool = False is_saved: bool = False archived: bool = False + pinned: 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 5d320f4..7fe0813 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -64,6 +64,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) 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) + pinned = bool(user_setting and user_setting.pinned) return ChatRead.model_validate( { @@ -77,6 +78,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) "is_public": chat.is_public, "is_saved": chat.is_saved, "archived": archived, + "pinned": pinned, "unread_count": unread_count, "pinned_message_id": chat.pinned_message_id, "members_count": members_count, @@ -501,3 +503,17 @@ async def set_chat_archived_for_user( await db.commit() await db.refresh(chat) return chat + + +async def set_chat_pinned_for_user( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + pinned: bool, +) -> Chat: + chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) + await repository.upsert_chat_pinned_setting(db, chat_id=chat_id, user_id=user_id, pinned=pinned) + await db.commit() + await db.refresh(chat) + return chat diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index ec882b8..05d26f4 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -198,6 +198,16 @@ export async function unarchiveChat(chatId: number): Promise { return data; } +export async function pinChat(chatId: number): Promise { + const { data } = await http.post(`/chats/${chatId}/pin-chat`); + return data; +} + +export async function unpinChat(chatId: number): Promise { + const { data } = await http.post(`/chats/${chatId}/unpin-chat`); + 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 99c2b45..c890388 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -13,6 +13,7 @@ export interface Chat { is_public?: boolean; is_saved?: boolean; archived?: boolean; + pinned?: 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 5847d3e..e41f8fc 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 { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, joinChat, unarchiveChat } from "../api/chats"; +import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { globalSearch } from "../api/search"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import { updateMyProfile } from "../api/users"; @@ -283,7 +283,7 @@ export function ChatList() { >
- {(chat.display_title || chat.title || chat.type).slice(0, 1)} + {chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
@@ -341,6 +341,29 @@ export function ChatList() { > {(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"} +
, document.body )