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) =>