feat(web-core): implement unread counters and new-messages divider
Some checks failed
CI / test (push) Failing after 19s

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
This commit is contained in:
2026-03-08 01:43:27 +03:00
parent 7f15edcb4e
commit 4ffbfc1e83
8 changed files with 88 additions and 6 deletions

View File

@@ -3,6 +3,7 @@ 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, ChatType 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: 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) stmt = select(ChatMember.user_id).where(ChatMember.chat_id == chat_id, ChatMember.user_id != user_id).limit(1)
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalar_one_or_none() 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)

View File

@@ -16,6 +16,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
unread_count: int = 0
pinned_message_id: int | None = None pinned_message_id: int | None = None
created_at: datetime created_at: datetime

View File

@@ -26,6 +26,8 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
if counterpart: if counterpart:
display_title = counterpart.name or counterpart.username 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( return ChatRead.model_validate(
{ {
"id": chat.id, "id": chat.id,
@@ -36,6 +38,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,
"unread_count": unread_count,
"pinned_message_id": chat.pinned_message_id, "pinned_message_id": chat.pinned_message_id,
"created_at": chat.created_at, "created_at": chat.created_at,
} }

View File

@@ -11,6 +11,7 @@ export interface Chat {
description?: string | null; description?: string | null;
is_public?: boolean; is_public?: boolean;
is_saved?: boolean; is_saved?: boolean;
unread_count?: number;
pinned_message_id?: number | null; pinned_message_id?: number | null;
created_at: string; created_at: string;
} }

View File

@@ -135,9 +135,15 @@ export function ChatList() {
<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">
<p className="truncate text-sm font-semibold">{chatLabel(chat)}</p> <p className="truncate text-sm font-semibold">{chatLabel(chat)}</p>
<span className="shrink-0 text-[11px] text-slate-400"> {(chat.unread_count ?? 0) > 0 ? (
{messagesByChat[chat.id]?.length ? "now" : ""} <span className="inline-flex min-w-5 items-center justify-center rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] font-semibold text-slate-950">
</span> {chat.unread_count}
</span>
) : (
<span className="shrink-0 text-[11px] text-slate-400">
{messagesByChat[chat.id]?.length ? "now" : ""}
</span>
)}
</div> </div>
<p className="truncate text-xs text-slate-400">{chat.type}</p> <p className="truncate text-xs text-slate-400">{chat.type}</p>
</div> </div>

View File

@@ -16,6 +16,7 @@ export function MessageList() {
const activeChatId = useChatStore((s) => s.activeChatId); const activeChatId = useChatStore((s) => s.activeChatId);
const messagesByChat = useChatStore((s) => s.messagesByChat); const messagesByChat = useChatStore((s) => s.messagesByChat);
const typingByChat = useChatStore((s) => s.typingByChat); const typingByChat = useChatStore((s) => s.typingByChat);
const unreadBoundaryByChat = useChatStore((s) => s.unreadBoundaryByChat);
const chats = useChatStore((s) => s.chats); const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage); const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
@@ -46,6 +47,8 @@ export function MessageList() {
return label.includes(q) || handle.includes(q); return label.includes(q) || handle.includes(q);
}); });
}, [chats, forwardQuery]); }, [chats, forwardQuery]);
const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0;
const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1;
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -110,11 +113,21 @@ export function MessageList() {
</div> </div>
) : null} ) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6"> <div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
{messages.map((message) => { {messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id; const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null; const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
return ( return (
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`} key={`${message.id}-${message.client_message_id ?? ""}`}> <div key={`${message.id}-${message.client_message_id ?? ""}`}>
{unreadBoundaryIndex === messageIndex ? (
<div className="mb-2 mt-1 flex items-center gap-2 px-1">
<span className="h-px flex-1 bg-slate-700/60" />
<span className="rounded-full border border-slate-700/70 bg-slate-900/80 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-sky-300">
New messages
</span>
<span className="h-px flex-1 bg-slate-700/60" />
</div>
) : null}
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
<div <div
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${ className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100" own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
@@ -142,6 +155,7 @@ export function MessageList() {
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null} {own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p> </p>
</div> </div>
</div>
</div> </div>
); );
})} })}

View File

@@ -69,6 +69,9 @@ export function useRealtime() {
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } })); ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
if (chatId === chatStore.activeChatId) { if (chatId === chatStore.activeChatId) {
ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } })); 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)) { if (!chatStore.chats.some((chat) => chat.id === chatId)) {

View File

@@ -8,6 +8,7 @@ interface ChatState {
messagesByChat: Record<number, Message[]>; messagesByChat: Record<number, Message[]>;
typingByChat: Record<number, number[]>; typingByChat: Record<number, number[]>;
replyToByChat: Record<number, Message | null>; replyToByChat: Record<number, Message | null>;
unreadBoundaryByChat: Record<number, number>;
loadChats: (query?: string) => Promise<void>; loadChats: (query?: string) => Promise<void>;
setActiveChatId: (chatId: number | null) => void; setActiveChatId: (chatId: number | null) => void;
loadMessages: (chatId: number) => Promise<void>; loadMessages: (chatId: number) => Promise<void>;
@@ -24,6 +25,8 @@ interface ChatState {
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void; setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
removeMessage: (chatId: number, messageId: number) => void; removeMessage: (chatId: number, messageId: number) => void;
clearChatMessages: (chatId: number) => void; clearChatMessages: (chatId: number) => void;
incrementUnread: (chatId: number) => void;
clearUnread: (chatId: number) => void;
setTypingUsers: (chatId: number, userIds: number[]) => void; setTypingUsers: (chatId: number, userIds: number[]) => void;
setReplyToMessage: (chatId: number, message: Message | null) => void; setReplyToMessage: (chatId: number, message: Message | null) => void;
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void; updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
@@ -35,6 +38,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
messagesByChat: {}, messagesByChat: {},
typingByChat: {}, typingByChat: {},
replyToByChat: {}, replyToByChat: {},
unreadBoundaryByChat: {},
loadChats: async (query) => { loadChats: async (query) => {
const chats = await getChats(query); const chats = await getChats(query);
const currentActive = get().activeChatId; const currentActive = get().activeChatId;
@@ -43,13 +47,19 @@ export const useChatStore = create<ChatState>((set, get) => ({
}, },
setActiveChatId: (chatId) => set({ activeChatId: chatId }), setActiveChatId: (chatId) => set({ activeChatId: chatId }),
loadMessages: async (chatId) => { loadMessages: async (chatId) => {
const unreadCount = get().chats.find((c) => c.id === chatId)?.unread_count ?? 0;
const messages = await getMessages(chatId); const messages = await getMessages(chatId);
const sorted = [...messages].reverse(); const sorted = [...messages].reverse();
set((state) => ({ set((state) => ({
messagesByChat: { messagesByChat: {
...state.messagesByChat, ...state.messagesByChat,
[chatId]: sorted [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]; const lastMessage = sorted[sorted.length - 1];
if (lastMessage) { if (lastMessage) {
@@ -153,6 +163,25 @@ export const useChatStore = create<ChatState>((set, get) => ({
messagesByChat: { messagesByChat: {
...state.messagesByChat, ...state.messagesByChat,
[chatId]: [] [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) => setTypingUsers: (chatId, userIds) =>