From 0594b890c3afa445f4e268566b8a8859ea7a83a0 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 12:23:39 +0300 Subject: [PATCH] feat: mentions badge in chat list and muted-mention delivery --- app/chats/repository.py | 36 +++++++++++++++++++ app/chats/schemas.py | 1 + app/chats/service.py | 25 +++++++++++-- app/notifications/service.py | 5 +-- docs/api-reference.md | 4 ++- web/src/chat/types.ts | 1 + web/src/components/ChatList.tsx | 29 ++++++++++++--- web/src/pages/ChatsPage.tsx | 64 --------------------------------- web/tsconfig.tsbuildinfo | 2 +- 9 files changed, 92 insertions(+), 75 deletions(-) diff --git a/app/chats/repository.py b/app/chats/repository.py index 88e53cf..82c2d84 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -237,6 +237,42 @@ async def get_unread_count_for_chat(db: AsyncSession, *, chat_id: int, user_id: return int(result.scalar_one() or 0) +async def get_unread_mentions_count_for_chat( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + username: str | None, +) -> int: + normalized_username = (username or "").strip().lower() + if not normalized_username: + return 0 + last_read_subquery = ( + select(MessageReceipt.last_read_message_id) + .where(MessageReceipt.chat_id == chat_id, MessageReceipt.user_id == user_id) + .limit(1) + .scalar_subquery() + ) + mention_like = f"%@{normalized_username}%" + 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), + Message.text.is_not(None), + func.lower(Message.text).like(mention_like), + ) + ) + result = await db.execute(stmt) + return int(result.scalar_one() or 0) + + async def get_last_visible_message_for_user( db: AsyncSession, *, diff --git a/app/chats/schemas.py b/app/chats/schemas.py index b1adedf..b095bee 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -21,6 +21,7 @@ class ChatRead(BaseModel): archived: bool = False pinned: bool = False unread_count: int = 0 + unread_mentions_count: int = 0 pinned_message_id: int | None = None members_count: int | None = None online_count: int | None = None diff --git a/app/chats/service.py b/app/chats/service.py index 9e2d536..4d5bb49 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -30,7 +30,13 @@ from app.realtime.presence import get_users_online_map from app.users.repository import get_user_by_id, has_block_relation_between_users -async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead: +async def serialize_chat_for_user( + db: AsyncSession, + *, + user_id: int, + chat: Chat, + current_username: str | None = None, +) -> ChatRead: display_title = chat.title my_role = None membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id) @@ -66,7 +72,16 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) if chat.type == ChatType.CHANNEL: subscribers_count = members_count + if not current_username: + current_user = await get_user_by_id(db, user_id) + current_username = current_user.username if current_user else None unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id) + unread_mentions_count = await repository.get_unread_mentions_count_for_chat( + db, + chat_id=chat.id, + user_id=user_id, + username=current_username, + ) last_message = await repository.get_last_visible_message_for_user(db, chat_id=chat.id, user_id=user_id) user_setting = await repository.get_chat_user_setting(db, chat_id=chat.id, user_id=user_id) archived = bool(user_setting and user_setting.archived) @@ -86,6 +101,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) "archived": archived, "pinned": pinned, "unread_count": unread_count, + "unread_mentions_count": unread_mentions_count, "pinned_message_id": chat.pinned_message_id, "members_count": members_count, "online_count": online_count, @@ -105,7 +121,12 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) async def serialize_chats_for_user(db: AsyncSession, *, user_id: int, chats: list[Chat]) -> list[ChatRead]: - return [await serialize_chat_for_user(db, user_id=user_id, chat=chat) for chat in chats] + current_user = await get_user_by_id(db, user_id) + current_username = current_user.username if current_user else None + return [ + await serialize_chat_for_user(db, user_id=user_id, chat=chat, current_username=current_username) + for chat in chats + ] async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat: diff --git a/app/notifications/service.py b/app/notifications/service.py index 11f478b..8f08995 100644 --- a/app/notifications/service.py +++ b/app/notifications/service.py @@ -44,8 +44,6 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) -> sender_name = sender_users[0].username if sender_users else "Someone" for recipient in users: - if await is_chat_muted_for_user(db, chat_id=message.chat_id, user_id=recipient.id): - continue base_payload = { "chat_id": message.chat_id, "message_id": message.id, @@ -71,6 +69,9 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) -> ) continue + if await is_chat_muted_for_user(db, chat_id=message.chat_id, user_id=recipient.id): + continue + if not await is_user_online(recipient.id): payload = { **base_payload, diff --git a/docs/api-reference.md b/docs/api-reference.md index d29619f..2baae0c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -190,6 +190,7 @@ All fields are optional. "archived": false, "pinned": false, "unread_count": 3, + "unread_mentions_count": 1, "pinned_message_id": null, "members_count": 2, "online_count": 1, @@ -748,6 +749,8 @@ Body: Response: `200` + `ChatNotificationSettingsRead` +Note: mentions (`@username`) are delivered even when chat is muted. + ### POST `/api/v1/chats/{chat_id}/archive` Auth required. @@ -935,4 +938,3 @@ Configured via env vars: - Invite links are generated for group/channel chats. - In channels, only users with sufficient role (owner/admin) can post. - `email` router exists in codebase but has no public REST endpoints yet. - diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 92bed0a..3f3a233 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -15,6 +15,7 @@ export interface Chat { archived?: boolean; pinned?: boolean; unread_count?: number; + unread_mentions_count?: number; pinned_message_id?: number | null; members_count?: number | null; online_count?: number | null; diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index d99dbd0..9946071 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -17,6 +17,7 @@ export function ChatList() { const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const loadChats = useChatStore((s) => s.loadChats); const me = useAuthStore((s) => s.me); + const logout = useAuthStore((s) => s.logout); const [search, setSearch] = useState(""); const [userResults, setUserResults] = useState([]); const [discoverResults, setDiscoverResults] = useState([]); @@ -234,9 +235,18 @@ export function ChatList() {

{chatLabel(chat)}

- {(chat.unread_count ?? 0) > 0 ? ( - - {chat.unread_count} + {(chat.unread_count ?? 0) > 0 || (chat.unread_mentions_count ?? 0) > 0 ? ( + + {(chat.unread_mentions_count ?? 0) > 0 ? ( + + @ + + ) : null} + {(chat.unread_count ?? 0) > 0 ? ( + + {chat.unread_count} + + ) : null} ) : ( @@ -281,8 +291,17 @@ export function ChatList() { -
) : null} diff --git a/web/src/pages/ChatsPage.tsx b/web/src/pages/ChatsPage.tsx index 8c64718..c81ba37 100644 --- a/web/src/pages/ChatsPage.tsx +++ b/web/src/pages/ChatsPage.tsx @@ -3,7 +3,6 @@ import { ChatList } from "../components/ChatList"; import { ChatInfoPanel } from "../components/ChatInfoPanel"; import { MessageComposer } from "../components/MessageComposer"; import { MessageList } from "../components/MessageList"; -import { getNotifications, type NotificationItem } from "../api/notifications"; import { searchMessages } from "../api/chats"; import type { Message } from "../chat/types"; import { useRealtime } from "../hooks/useRealtime"; @@ -14,7 +13,6 @@ import { useState } from "react"; export function ChatsPage() { const me = useAuthStore((s) => s.me); - const logout = useAuthStore((s) => s.logout); const loadChats = useChatStore((s) => s.loadChats); const activeChatId = useChatStore((s) => s.activeChatId); const chats = useChatStore((s) => s.chats); @@ -29,9 +27,6 @@ export function ChatsPage() { const [searchLoading, setSearchLoading] = useState(false); const [searchResults, setSearchResults] = useState([]); const [searchActiveIndex, setSearchActiveIndex] = useState(0); - const [notificationsOpen, setNotificationsOpen] = useState(false); - const [notifications, setNotifications] = useState([]); - const [loadingNotifications, setLoadingNotifications] = useState(false); const searchInputRef = useRef(null); const activeTrack = useAudioPlayerStore((s) => s.track); const isAudioPlaying = useAudioPlayerStore((s) => s.isPlaying); @@ -139,33 +134,6 @@ export function ChatsPage() { setFocusedMessage(current.chat_id, current.id); }, [searchOpen, searchActiveIndex, searchResults, setFocusedMessage]); - useEffect(() => { - if (!notificationsOpen) { - return; - } - let cancelled = false; - setLoadingNotifications(true); - void (async () => { - try { - const items = await getNotifications(30); - if (!cancelled) { - setNotifications(items); - } - } catch { - if (!cancelled) { - setNotifications([]); - } - } finally { - if (!cancelled) { - setLoadingNotifications(false); - } - } - })(); - return () => { - cancelled = true; - }; - }, [notificationsOpen]); - return (
@@ -192,15 +160,6 @@ export function ChatsPage() {
- -
) : ( @@ -311,26 +267,6 @@ export function ChatsPage() { setInfoOpen(false)} /> - {notificationsOpen ? ( -
setNotificationsOpen(false)}> -
e.stopPropagation()}> -
-

Notifications

- -
- {loadingNotifications ?

Loading...

: null} - {!loadingNotifications && notifications.length === 0 ?

No notifications

: null} -
- {notifications.map((item) => ( -
-

{item.event_type}

-

{new Date(item.created_at).toLocaleString()}

-
- ))} -
-
-
- ) : null} ); } diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 4fab97e..96dc05e 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file