Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions src/app/features/room-nav/RoomNavItem.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -243,6 +265,7 @@ export function RoomNavItem({
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const hasRoomUnread = useRoomHasUnread(room);
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
Expand Down Expand Up @@ -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()
Expand All @@ -338,7 +365,7 @@ export function RoomNavItem({
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
highlight={unread !== undefined || hasRoomUnread}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
Expand Down Expand Up @@ -368,7 +395,10 @@ export function RoomNavItem({
) : (
<RoomIcon
style={{
opacity: unread || isActiveCall ? config.opacity.P500 : config.opacity.P300,
opacity:
unread || hasRoomUnread || isActiveCall
? config.opacity.P500
: config.opacity.P300,
}}
filled={selected || isActiveCall}
size="100"
Expand All @@ -379,7 +409,7 @@ export function RoomNavItem({
</Avatar>
<Box as="span" grow="Yes">
<Text
priority={unread || isActiveCall ? '500' : '300'}
priority={unread || hasRoomUnread || isActiveCall ? '500' : '300'}
as="span"
size="Inherit"
truncate
Expand All @@ -392,9 +422,9 @@ export function RoomNavItem({
<TypingIndicator size="300" disableAnimation />
</Badge>
)}
{!optionsVisible && unread && (
{!optionsVisible && (unread || hasRoomUnread) && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
<UnreadBadge highlight={!!unread && unread.highlight > 0} count={unreadCount} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ export function Search({ requestClose }: SearchProps) {
<UnreadBadgeCenter>
<UnreadBadge
highlight={unread.highlight > 0}
count={unread.total}
count={unread.highlight > 0 ? unread.highlight : unread.total}
/>
</UnreadBadgeCenter>
)}
Expand Down
5 changes: 4 additions & 1 deletion src/app/pages/client/sidebar/DirectTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ export function DirectTab() {
</SidebarItemTooltip>
{directUnread && (
<SidebarItemBadge hasCount={directUnread.total > 0}>
<UnreadBadge highlight={directUnread.highlight > 0} count={directUnread.total} />
<UnreadBadge
highlight={directUnread.highlight > 0}
count={directUnread.highlight > 0 ? directUnread.highlight : directUnread.total}
/>
</SidebarItemBadge>
)}
{menuAnchor && (
Expand Down
5 changes: 4 additions & 1 deletion src/app/pages/client/sidebar/HomeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ export function HomeTab() {
</SidebarItemTooltip>
{homeUnread && (
<SidebarItemBadge hasCount={homeUnread.total > 0}>
<UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
<UnreadBadge
highlight={homeUnread.highlight > 0}
count={homeUnread.highlight > 0 ? homeUnread.highlight : homeUnread.total}
/>
</SidebarItemBadge>
)}
{menuAnchor && (
Expand Down
10 changes: 8 additions & 2 deletions src/app/pages/client/sidebar/SpaceTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,10 @@ function SpaceTab({
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
<UnreadBadge
highlight={unread.highlight > 0}
count={unread.highlight > 0 ? unread.highlight : unread.total}
/>
</SidebarItemBadge>
)}
{menuAnchor && (
Expand Down Expand Up @@ -598,7 +601,10 @@ function ClosedSpaceFolder({
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
<UnreadBadge
highlight={unread.highlight > 0}
count={unread.highlight > 0 ? unread.highlight : unread.total}
/>
</SidebarItemBadge>
)}
</SidebarItem>
Expand Down
25 changes: 9 additions & 16 deletions src/app/state/room/roomToUnread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
| {
Expand Down Expand Up @@ -122,14 +123,14 @@ const baseRoomToUnread = atom<RoomToUnread>(new Map());
export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
(get) => get(baseRoomToUnread),
(get, set, action) => {
const dmRoomIds = get(mDirectAtom);
const getEffectiveParents = (roomId: string): Set<string> =>
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;
Expand All @@ -143,7 +144,7 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
deleteUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
getEffectiveParents(unreadInfo.roomId),
unreadInfo.roomId
)
)
Expand All @@ -160,11 +161,7 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
putUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
unreadInfo
)
putUnreadInfo(draftRoomToUnread, getEffectiveParents(unreadInfo.roomId), unreadInfo)
)
);
return;
Expand All @@ -173,11 +170,7 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
deleteUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), action.roomId),
action.roomId
)
deleteUnreadInfo(draftRoomToUnread, getEffectiveParents(action.roomId), action.roomId)
)
);
}
Expand Down
21 changes: 13 additions & 8 deletions src/app/utils/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
Loading