feat(web-core): implement unread counters and new-messages divider
Some checks failed
CI / test (push) Failing after 19s
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -135,9 +135,15 @@ export function ChatList() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-semibold">{chatLabel(chat)}</p>
|
||||
<span className="shrink-0 text-[11px] text-slate-400">
|
||||
{messagesByChat[chat.id]?.length ? "now" : ""}
|
||||
</span>
|
||||
{(chat.unread_count ?? 0) > 0 ? (
|
||||
<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">
|
||||
{chat.unread_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[11px] text-slate-400">
|
||||
{messagesByChat[chat.id]?.length ? "now" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-slate-400">{chat.type}</p>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : null}
|
||||
<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 replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
||||
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
|
||||
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"
|
||||
@@ -142,6 +155,7 @@ export function MessageList() {
|
||||
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ChatState {
|
||||
messagesByChat: Record<number, Message[]>;
|
||||
typingByChat: Record<number, number[]>;
|
||||
replyToByChat: Record<number, Message | null>;
|
||||
unreadBoundaryByChat: Record<number, number>;
|
||||
loadChats: (query?: string) => Promise<void>;
|
||||
setActiveChatId: (chatId: number | null) => void;
|
||||
loadMessages: (chatId: number) => Promise<void>;
|
||||
@@ -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<ChatState>((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<ChatState>((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<ChatState>((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) =>
|
||||
|
||||
Reference in New Issue
Block a user