feat(chats): add per-user pinned chats and pinned sorting
This commit is contained in:
28
alembic/versions/0015_chat_pin_fields.py
Normal file
28
alembic/versions/0015_chat_pin_fields.py
Normal file
@@ -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")
|
||||||
|
|
||||||
@@ -85,6 +85,8 @@ class ChatUserSetting(Base):
|
|||||||
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, 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)
|
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")
|
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(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Ch
|
|||||||
Chat.type.cast(String).ilike(q),
|
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(
|
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),
|
(ChatUserSetting.chat_id == Chat.id) & (ChatUserSetting.user_id == user_id),
|
||||||
)
|
)
|
||||||
.where(ChatMember.user_id == user_id, ChatUserSetting.archived.is_(True))
|
.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)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
if before_id is not None:
|
if before_id is not None:
|
||||||
@@ -287,3 +295,28 @@ async def upsert_chat_archived_setting(
|
|||||||
db.add(setting)
|
db.add(setting)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return setting
|
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
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from app.chats.service import (
|
|||||||
serialize_chat_for_user,
|
serialize_chat_for_user,
|
||||||
serialize_chats_for_user,
|
serialize_chats_for_user,
|
||||||
set_chat_archived_for_user,
|
set_chat_archived_for_user,
|
||||||
|
set_chat_pinned_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,
|
||||||
@@ -257,3 +258,23 @@ async def unarchive_chat(
|
|||||||
) -> ChatRead:
|
) -> ChatRead:
|
||||||
chat = await set_chat_archived_for_user(db, chat_id=chat_id, user_id=current_user.id, archived=False)
|
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)
|
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)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ChatRead(BaseModel):
|
|||||||
is_public: bool = False
|
is_public: bool = False
|
||||||
is_saved: bool = False
|
is_saved: bool = False
|
||||||
archived: bool = False
|
archived: bool = False
|
||||||
|
pinned: 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
|
||||||
|
|||||||
@@ -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)
|
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)
|
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)
|
archived = bool(user_setting and user_setting.archived)
|
||||||
|
pinned = bool(user_setting and user_setting.pinned)
|
||||||
|
|
||||||
return ChatRead.model_validate(
|
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_public": chat.is_public,
|
||||||
"is_saved": chat.is_saved,
|
"is_saved": chat.is_saved,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
|
"pinned": pinned,
|
||||||
"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,
|
||||||
@@ -501,3 +503,17 @@ async def set_chat_archived_for_user(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(chat)
|
await db.refresh(chat)
|
||||||
return 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
|
||||||
|
|||||||
@@ -198,6 +198,16 @@ export async function unarchiveChat(chatId: number): Promise<Chat> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pinChat(chatId: number): Promise<Chat> {
|
||||||
|
const { data } = await http.post<Chat>(`/chats/${chatId}/pin-chat`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpinChat(chatId: number): Promise<Chat> {
|
||||||
|
const { data } = await http.post<Chat>(`/chats/${chatId}/unpin-chat`);
|
||||||
|
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 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Chat {
|
|||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
is_saved?: boolean;
|
is_saved?: boolean;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
|
pinned?: 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;
|
||||||
|
|||||||
@@ -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 { 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 { 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";
|
||||||
@@ -283,7 +283,7 @@ export function ChatList() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
||||||
{(chat.display_title || chat.title || chat.type).slice(0, 1)}
|
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -341,6 +341,29 @@ export function ChatList() {
|
|||||||
>
|
>
|
||||||
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"}
|
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"}
|
||||||
</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.pinned) {
|
||||||
|
await unpinChat(target.id);
|
||||||
|
} else {
|
||||||
|
await pinChat(target.id);
|
||||||
|
}
|
||||||
|
await loadChats();
|
||||||
|
if (tab === "archived") {
|
||||||
|
const nextArchived = await getChats(undefined, true);
|
||||||
|
setArchivedChats(nextArchived);
|
||||||
|
}
|
||||||
|
setCtxChatId(null);
|
||||||
|
setCtxPos(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.pinned ? "Unpin chat" : "Pin chat"}
|
||||||
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user