Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/fix-direct-tab-double-badge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix duplicate unread badges on the /direct/ icon for DM rooms already shown as individual sidebar avatars
61 changes: 11 additions & 50 deletions src/app/pages/client/sidebar/DirectDMsList.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 10 additions & 2 deletions src/app/pages/client/sidebar/DirectTab.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<RectCords>();

const directSelected = useDirectSelected();
Expand Down
54 changes: 54 additions & 0 deletions src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
Loading