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

@@ -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>

View File

@@ -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>
);
})}