From 0f7e6497e4cb69b1baf3343c5fc404e11ef06f28 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 14:47:43 -0400 Subject: [PATCH 1/2] fix(direct): prevent double-badge on /direct/ icon for rooms in sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /direct/ icon badge was counting unread for all DMs, including those already displayed as individual avatar entries in the sidebar via DirectDMsList. This caused users to see duplicate badges — one on the avatar entry and one on the /direct/ icon — for the same room. Extract useSidebarDirectRoomIds hook (shared by DirectDMsList and DirectTab) that returns the room IDs currently shown as sidebar avatars. DirectTab now computes its badge only for DMs that overflow beyond the MAX_SIDEBAR_DMS (3) limit shown in the sidebar. Closes #235 --- .../pages/client/sidebar/DirectDMsList.tsx | 62 ++++--------------- src/app/pages/client/sidebar/DirectTab.tsx | 12 +++- .../client/sidebar/useSidebarDirectRoomIds.ts | 54 ++++++++++++++++ 3 files changed, 75 insertions(+), 53 deletions(-) create mode 100644 src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 81d9f26d..58f9365d 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,12 +1,9 @@ -import { useMemo, useState, useCallback } from 'react'; +import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box, toRem } from 'folds'; import { useAtomValue } from 'jotai'; -import { Room, SyncState } from '$types/matrix-sdk'; -import { useDirects } from '$state/hooks/roomList'; +import { Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { mDirectAtom } from '$state/mDirectList'; -import { allRoomsAtom } from '$state/room-list/roomList'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { getDirectRoomPath } from '$pages/pathUtils'; import { @@ -21,14 +18,11 @@ import { UserAvatar } from '$components/user-avatar'; import { getDirectRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; -import { factoryRoomIdByActivity } from '$utils/sort'; import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; -import { useSyncState } from '$hooks/useSyncState'; +import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import * as css from './DirectDMsList.css'; - -const MAX_DM_AVATARS = 3; const MAX_GROUP_MEMBERS = 3; type DMItemProps = { @@ -163,51 +157,17 @@ function DMItem({ room, selected }: DMItemProps) { export function DirectDMsList() { const mx = useMatrixClient(); - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const roomToUnread = useAtomValue(roomToUnreadAtom); const selectedRoomId = useSelectedRoom(); - - // Track sync state to wait for initial sync completion - const [syncReady, setSyncReady] = useState(false); - - useSyncState( - mx, - useCallback((state, prevState) => { - // Consider ready after initial sync reaches Syncing state - // This ensures m.direct and unread counts are populated - if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { - setSyncReady(true); - } - // Also set ready if we're already syncing (e.g., after a refresh while still online) - if (state === SyncState.Syncing || state === SyncState.Catchup) { - setSyncReady(true); - } - }, []) + const sidebarRoomIds = useSidebarDirectRoomIds(); + + const recentDMs = useMemo( + () => + sidebarRoomIds + .map((roomId) => mx.getRoom(roomId)) + .filter((room): room is Room => room !== null), + [sidebarRoomIds, mx] ); - // Get up to MAX_DM_AVATARS recent DMs that have unread messages - const recentDMs = useMemo(() => { - // Don't show DMs until initial sync completes - if (!syncReady) { - return []; - } - - // Filter to only DMs with unread messages - const withUnread = directs.filter((roomId) => { - const unread = roomToUnread.get(roomId); - return unread && (unread.total > 0 || unread.highlight > 0); - }); - - // Sort by activity - const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); - - return sorted - .slice(0, MAX_DM_AVATARS) - .map((roomId) => mx.getRoom(roomId)) - .filter((room): room is Room => room !== null); - }, [directs, mx, roomToUnread, syncReady]); - if (recentDMs.length === 0) { return null; } diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index dd2a7d6e..60894f51 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, forwardRef, useState } from 'react'; +import { MouseEventHandler, forwardRef, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; import FocusTrap from 'focus-trap-react'; @@ -8,6 +8,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { mDirectAtom } from '$state/mDirectList'; import { allRoomsAtom } from '$state/room-list/roomList'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; +import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import { getDirectPath, joinPathComponent } from '$pages/pathUtils'; import { useRoomsUnread } from '$state/hooks/unread'; import { @@ -68,7 +69,14 @@ export function DirectTab() { const mDirects = useAtomValue(mDirectAtom); const directs = useDirects(mx, allRoomsAtom, mDirects); - const directUnread = useRoomsUnread(directs, roomToUnreadAtom); + const sidebarRoomIds = useSidebarDirectRoomIds(); + // Only count unread for DMs not already shown as individual avatars in the + // sidebar — prevents double-badging (issue #235). + const overflowDirects = useMemo(() => { + const sidebarSet = new Set(sidebarRoomIds); + return directs.filter((id) => !sidebarSet.has(id)); + }, [directs, sidebarRoomIds]); + const directUnread = useRoomsUnread(overflowDirects, roomToUnreadAtom); const [menuAnchor, setMenuAnchor] = useState(); const directSelected = useDirectSelected(); diff --git a/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts b/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts new file mode 100644 index 00000000..8c62d3af --- /dev/null +++ b/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts @@ -0,0 +1,54 @@ +import { useMemo, useState, useCallback } from 'react'; +import { useAtomValue } from 'jotai'; +import { SyncState } from '$types/matrix-sdk'; +import { useDirects } from '$state/hooks/roomList'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { mDirectAtom } from '$state/mDirectList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { roomToUnreadAtom } from '$state/room/roomToUnread'; +import { factoryRoomIdByActivity } from '$utils/sort'; +import { useSyncState } from '$hooks/useSyncState'; + +/** Maximum number of individual DM avatars shown in the sidebar. */ +export const MAX_SIDEBAR_DMS = 3; + +/** + * Returns the room IDs of DMs currently displayed as individual avatars in the + * sidebar `DirectDMsList`. These are the first `MAX_SIDEBAR_DMS` unread DMs + * sorted by recent activity, available only after initial sync completes. + * + * Used by `DirectDMsList` to decide which rooms to render, and by `DirectTab` + * to exclude those rooms from its own badge count (prevents double-badging). + */ +export const useSidebarDirectRoomIds = (): string[] => { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const roomToUnread = useAtomValue(roomToUnreadAtom); + + const [syncReady, setSyncReady] = useState(false); + + useSyncState( + mx, + useCallback((state, prevState) => { + if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { + setSyncReady(true); + } + if (state === SyncState.Syncing || state === SyncState.Catchup) { + setSyncReady(true); + } + }, []) + ); + + return useMemo(() => { + if (!syncReady) return []; + + const withUnread = directs.filter((roomId) => { + const unread = roomToUnread.get(roomId); + return unread && (unread.total > 0 || unread.highlight > 0); + }); + + const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); + return sorted.slice(0, MAX_SIDEBAR_DMS); + }, [directs, mx, roomToUnread, syncReady]); +}; From f8bb692093353edde0af99522ce99860b0d66819 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 14:54:46 -0400 Subject: [PATCH 2/2] chore: fix import order and add changeset --- .changeset/fix-direct-tab-double-badge.md | 5 +++++ src/app/pages/client/sidebar/DirectDMsList.tsx | 1 + src/app/pages/client/sidebar/DirectTab.tsx | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-direct-tab-double-badge.md diff --git a/.changeset/fix-direct-tab-double-badge.md b/.changeset/fix-direct-tab-double-badge.md new file mode 100644 index 00000000..6d050636 --- /dev/null +++ b/.changeset/fix-direct-tab-double-badge.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix duplicate unread badges on the /direct/ icon for DM rooms already shown as individual sidebar avatars diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 58f9365d..f2573b61 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -23,6 +23,7 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import * as css from './DirectDMsList.css'; + const MAX_GROUP_MEMBERS = 3; type DMItemProps = { diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index 60894f51..3503c7f9 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -8,7 +8,6 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { mDirectAtom } from '$state/mDirectList'; import { allRoomsAtom } from '$state/room-list/roomList'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; -import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import { getDirectPath, joinPathComponent } from '$pages/pathUtils'; import { useRoomsUnread } from '$state/hooks/unread'; import { @@ -26,6 +25,7 @@ import { stopPropagation } from '$utils/keyboard'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useDirectRooms } from '$pages/client/direct/useDirectRooms'; +import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; type DirectMenuProps = { requestClose: () => void;