diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index d4368c3e5c..9a3d3b3aba 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -1,5 +1,5 @@ -import { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react'; -import { EventType, Room } from '$types/matrix-sdk'; +import { MouseEventHandler, forwardRef, useState, MouseEvent, useEffect } from 'react'; +import { EventType, Room, RoomEvent as RoomEventEnum } from '$types/matrix-sdk'; import { Avatar, Box, @@ -25,7 +25,7 @@ import { useNavigate } from 'react-router-dom'; import { NavButton, NavItem, NavItemContent, NavItemOptions } from '$components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import { RoomAvatar, RoomIcon } from '$components/room-avatar'; -import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl, roomHaveUnread } from '$utils/room'; import { nameInitials } from '$utils/common'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useRoomUnread } from '$state/hooks/unread'; @@ -63,6 +63,28 @@ import { useAtomValue } from 'jotai'; import { nicknamesAtom } from '$state/nicknames'; import { RoomNavUser } from './RoomNavUser'; +/** + * Reactively checks whether a room has unread messages, even if the + * push-rule notification count is zero (e.g. mentions-only rooms with + * new regular messages). + */ +function useRoomHasUnread(room: Room): boolean { + const mx = useMatrixClient(); + const [hasUnread, setHasUnread] = useState(() => roomHaveUnread(mx, room)); + + useEffect(() => { + const update = () => setHasUnread(roomHaveUnread(mx, room)); + room.on(RoomEventEnum.Timeline, update); + room.on(RoomEventEnum.Receipt, update); + return () => { + room.removeListener(RoomEventEnum.Timeline, update); + room.removeListener(RoomEventEnum.Receipt, update); + }; + }, [room, mx]); + + return hasUnread; +} + type RoomNavItemMenuProps = { room: Room; requestClose: () => void; @@ -243,6 +265,7 @@ export function RoomNavItem({ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); + const hasRoomUnread = useRoomHasUnread(room); const typingMember = useRoomTypingMember(room.roomId).filter( (receipt) => receipt.userId !== mx.getUserId() ); @@ -318,6 +341,10 @@ export function RoomNavItem({ }; const optionsVisible = hover || !!menuAnchor; + let unreadCount = 0; + if (unread) { + unreadCount = unread.highlight > 0 ? unread.highlight : unread.total; + } const ariaLabel = [ roomName, room.isCallRoom() @@ -338,7 +365,7 @@ export function RoomNavItem({ )} - {!optionsVisible && unread && ( + {!optionsVisible && (unread || hasRoomUnread) && ( - 0} count={unread.total} /> + 0} count={unreadCount} /> )} {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index e326bf04f7..9d4ea3519c 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -342,7 +342,7 @@ export function Search({ requestClose }: SearchProps) { 0} - count={unread.total} + count={unread.highlight > 0 ? unread.highlight : unread.total} /> )} diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index 01d7ce3f89..37473bf863 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -108,7 +108,10 @@ export function DirectTab() { {directUnread && ( 0}> - 0} count={directUnread.total} /> + 0} + count={directUnread.highlight > 0 ? directUnread.highlight : directUnread.total} + /> )} {menuAnchor && ( diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index 74f7adfa9d..a19aee989b 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -110,7 +110,10 @@ export function HomeTab() { {homeUnread && ( 0}> - 0} count={homeUnread.total} /> + 0} + count={homeUnread.highlight > 0 ? homeUnread.highlight : homeUnread.total} + /> )} {menuAnchor && ( diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 99f54040a5..bc3927da5f 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -466,7 +466,10 @@ function SpaceTab({ {unread && ( 0}> - 0} count={unread.total} /> + 0} + count={unread.highlight > 0 ? unread.highlight : unread.total} + /> )} {menuAnchor && ( @@ -598,7 +601,10 @@ function ClosedSpaceFolder({ {unread && ( 0}> - 0} count={unread.total} /> + 0} + count={unread.highlight > 0 ? unread.highlight : unread.total} + /> )} diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 7d12691c5d..706b4bd7f7 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -32,6 +32,7 @@ import { useSyncState } from '$hooks/useSyncState'; import { useRoomsNotificationPreferencesContext } from '$hooks/useRoomsNotificationPreferences'; import { getClientSyncDiagnostics } from '$client/initMatrix'; import { roomToParentsAtom } from './roomToParents'; +import { mDirectAtom } from '../mDirectList'; export type RoomToUnreadAction = | { @@ -122,14 +123,14 @@ const baseRoomToUnread = atom(new Map()); export const roomToUnreadAtom = atom( (get) => get(baseRoomToUnread), (get, set, action) => { + const dmRoomIds = get(mDirectAtom); + const getEffectiveParents = (roomId: string): Set => + dmRoomIds.has(roomId) ? new Set() : getAllParents(get(roomToParentsAtom), roomId); + if (action.type === 'RESET') { const draftRoomToUnread: RoomToUnread = new Map(); action.unreadInfos.forEach((unreadInfo) => { - putUnreadInfo( - draftRoomToUnread, - getAllParents(get(roomToParentsAtom), unreadInfo.roomId), - unreadInfo - ); + putUnreadInfo(draftRoomToUnread, getEffectiveParents(unreadInfo.roomId), unreadInfo); }); set(baseRoomToUnread, draftRoomToUnread); return; @@ -143,7 +144,7 @@ export const roomToUnreadAtom = atom deleteUnreadInfo( draftRoomToUnread, - getAllParents(get(roomToParentsAtom), unreadInfo.roomId), + getEffectiveParents(unreadInfo.roomId), unreadInfo.roomId ) ) @@ -160,11 +161,7 @@ export const roomToUnreadAtom = atom - putUnreadInfo( - draftRoomToUnread, - getAllParents(get(roomToParentsAtom), unreadInfo.roomId), - unreadInfo - ) + putUnreadInfo(draftRoomToUnread, getEffectiveParents(unreadInfo.roomId), unreadInfo) ) ); return; @@ -173,11 +170,7 @@ export const roomToUnreadAtom = atom - deleteUnreadInfo( - draftRoomToUnread, - getAllParents(get(roomToParentsAtom), action.roomId), - action.roomId - ) + deleteUnreadInfo(draftRoomToUnread, getEffectiveParents(action.roomId), action.roomId) ) ); } diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index cadd0e9539..aed17e84b6 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -243,15 +243,20 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); let syntheticDotUnread = false; - // If our latest notification event is confirmed read, clamp stale non-highlight totals. + // If our latest main-timeline notification event is confirmed read, clamp its stale count. + // Only apply to the room (non-thread) portion so thread reply counts are preserved. if (userId && total > 0 && highlight === 0) { - const liveEvents = room.getLiveTimeline().getEvents(); - const latestNotification = [...liveEvents] - .reverse() - .find((event) => !event.isSending() && isNotificationEvent(event)); - const latestNotificationId = latestNotification?.getId(); - if (latestNotificationId && room.hasUserReadEvent(userId, latestNotificationId)) { - total = 0; + const roomTotal = room.getRoomUnreadNotificationCount(NotificationCountType.Total); + if (roomTotal > 0) { + const liveEvents = room.getLiveTimeline().getEvents(); + const latestNotification = [...liveEvents] + .reverse() + .find((event) => !event.isSending() && isNotificationEvent(event)); + const latestNotificationId = latestNotification?.getId(); + if (latestNotificationId && room.hasUserReadEvent(userId, latestNotificationId)) { + // Subtract only the stale main-timeline count; thread totals remain intact. + total -= roomTotal; + } } }