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 81d9f26d..f2573b61 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,12 @@ 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 +158,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..3503c7f9 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'; @@ -25,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; @@ -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]); +};