From 4ffbfc1e83ab53c27a745593ab532868064b9f82 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 01:43:27 +0300 Subject: [PATCH] feat(web-core): implement unread counters and new-messages divider backend: - add unread_count to ChatRead serialization - compute unread_count per chat using message_receipts and hidden messages web: - add unread badges in chat list - track unread boundary per chat in store - show 'New messages' divider in message list - update realtime flow to increment/clear unread on incoming events --- app/chats/repository.py | 25 ++++++++++++++++++++++++ app/chats/schemas.py | 1 + app/chats/service.py | 3 +++ web/src/chat/types.ts | 1 + web/src/components/ChatList.tsx | 12 +++++++++--- web/src/components/MessageList.tsx | 18 +++++++++++++++-- web/src/hooks/useRealtime.ts | 3 +++ web/src/store/chatStore.ts | 31 +++++++++++++++++++++++++++++- 8 files changed, 88 insertions(+), 6 deletions(-) diff --git a/app/chats/repository.py b/app/chats/repository.py index bdaf3de..25d13ad 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType +from app.messages.models import Message, MessageHidden, MessageReceipt async def create_chat(db: AsyncSession, *, chat_type: ChatType, title: str | None) -> Chat: @@ -165,3 +166,27 @@ async def get_private_counterpart_user_id(db: AsyncSession, *, chat_id: int, use stmt = select(ChatMember.user_id).where(ChatMember.chat_id == chat_id, ChatMember.user_id != user_id).limit(1) result = await db.execute(stmt) return result.scalar_one_or_none() + + +async def get_unread_count_for_chat(db: AsyncSession, *, chat_id: int, user_id: int) -> int: + last_read_subquery = ( + select(MessageReceipt.last_read_message_id) + .where(MessageReceipt.chat_id == chat_id, MessageReceipt.user_id == user_id) + .limit(1) + .scalar_subquery() + ) + stmt = ( + select(func.count(Message.id)) + .outerjoin( + MessageHidden, + (MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id), + ) + .where( + Message.chat_id == chat_id, + Message.sender_id != user_id, + MessageHidden.id.is_(None), + Message.id > func.coalesce(last_read_subquery, 0), + ) + ) + result = await db.execute(stmt) + return int(result.scalar_one() or 0) diff --git a/app/chats/schemas.py b/app/chats/schemas.py index 71387c8..e5b3343 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -16,6 +16,7 @@ class ChatRead(BaseModel): description: str | None = None is_public: bool = False is_saved: bool = False + unread_count: int = 0 pinned_message_id: int | None = None created_at: datetime diff --git a/app/chats/service.py b/app/chats/service.py index 627d02a..9a2682a 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -26,6 +26,8 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) if counterpart: display_title = counterpart.name or counterpart.username + unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id) + return ChatRead.model_validate( { "id": chat.id, @@ -36,6 +38,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, + "unread_count": unread_count, "pinned_message_id": chat.pinned_message_id, "created_at": chat.created_at, } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index e7831a8..2133212 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -11,6 +11,7 @@ export interface Chat { description?: string | null; is_public?: boolean; is_saved?: boolean; + unread_count?: number; pinned_message_id?: number | null; created_at: string; } diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index 2c01e5d..1694549 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -135,9 +135,15 @@ export function ChatList() {

{chatLabel(chat)}

- - {messagesByChat[chat.id]?.length ? "now" : ""} - + {(chat.unread_count ?? 0) > 0 ? ( + + {chat.unread_count} + + ) : ( + + {messagesByChat[chat.id]?.length ? "now" : ""} + + )}

{chat.type}

diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 4a0ba00..8299ed9 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -16,6 +16,7 @@ export function MessageList() { const activeChatId = useChatStore((s) => s.activeChatId); const messagesByChat = useChatStore((s) => s.messagesByChat); const typingByChat = useChatStore((s) => s.typingByChat); + const unreadBoundaryByChat = useChatStore((s) => s.unreadBoundaryByChat); const chats = useChatStore((s) => s.chats); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage); @@ -46,6 +47,8 @@ export function MessageList() { return label.includes(q) || handle.includes(q); }); }, [chats, forwardQuery]); + const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0; + const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -110,11 +113,21 @@ export function MessageList() { ) : null}
- {messages.map((message) => { + {messages.map((message, messageIndex) => { const own = message.sender_id === me?.id; const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null; return ( -
+
+ {unreadBoundaryIndex === messageIndex ? ( +
+ + + New messages + + +
+ ) : null} +
{renderStatus(message.delivery_status)} : null}

+
); })} diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index a28fa7c..34cc326 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -69,6 +69,9 @@ export function useRealtime() { ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } })); if (chatId === chatStore.activeChatId) { ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } })); + chatStore.clearUnread(chatId); + } else { + chatStore.incrementUnread(chatId); } } if (!chatStore.chats.some((chat) => chat.id === chatId)) { diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 6060c20..e4bebd9 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -8,6 +8,7 @@ interface ChatState { messagesByChat: Record; typingByChat: Record; replyToByChat: Record; + unreadBoundaryByChat: Record; loadChats: (query?: string) => Promise; setActiveChatId: (chatId: number | null) => void; loadMessages: (chatId: number) => Promise; @@ -24,6 +25,8 @@ interface ChatState { setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void; removeMessage: (chatId: number, messageId: number) => void; clearChatMessages: (chatId: number) => void; + incrementUnread: (chatId: number) => void; + clearUnread: (chatId: number) => void; setTypingUsers: (chatId: number, userIds: number[]) => void; setReplyToMessage: (chatId: number, message: Message | null) => void; updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void; @@ -35,6 +38,7 @@ export const useChatStore = create((set, get) => ({ messagesByChat: {}, typingByChat: {}, replyToByChat: {}, + unreadBoundaryByChat: {}, loadChats: async (query) => { const chats = await getChats(query); const currentActive = get().activeChatId; @@ -43,13 +47,19 @@ export const useChatStore = create((set, get) => ({ }, setActiveChatId: (chatId) => set({ activeChatId: chatId }), loadMessages: async (chatId) => { + const unreadCount = get().chats.find((c) => c.id === chatId)?.unread_count ?? 0; const messages = await getMessages(chatId); const sorted = [...messages].reverse(); set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: sorted - } + }, + unreadBoundaryByChat: { + ...state.unreadBoundaryByChat, + [chatId]: unreadCount + }, + chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, unread_count: 0 } : chat)) })); const lastMessage = sorted[sorted.length - 1]; if (lastMessage) { @@ -153,6 +163,25 @@ export const useChatStore = create((set, get) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: [] + }, + unreadBoundaryByChat: { + ...state.unreadBoundaryByChat, + [chatId]: 0 + }, + chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, unread_count: 0 } : chat)) + })), + incrementUnread: (chatId) => + set((state) => ({ + chats: state.chats.map((chat) => + chat.id === chatId ? { ...chat, unread_count: (chat.unread_count ?? 0) + 1 } : chat + ) + })), + clearUnread: (chatId) => + set((state) => ({ + chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, unread_count: 0 } : chat)), + unreadBoundaryByChat: { + ...state.unreadBoundaryByChat, + [chatId]: 0 } })), setTypingUsers: (chatId, userIds) =>