diff --git a/apps/web/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx b/apps/web/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx index 93d231aaf2e..a351f4a657d 100644 --- a/apps/web/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx +++ b/apps/web/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx @@ -8,7 +8,7 @@ import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { useEffect, useState } from "react"; -import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator"; import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag"; @@ -41,7 +41,7 @@ export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState { const roomMember = useDmMember(room); const presence = usePresence(room, roomMember); const isPublic = useIsPublic(room); - const isLowPriority = !!room.tags[DefaultTagID.LowPriority]; + const isLowPriority = useTypedEventEmitterState(room, RoomEvent.Tags, () => !!room.tags[DefaultTagID.LowPriority]); let badgeDecoration: AvatarBadgeDecoration | undefined; if (isLowPriority) { diff --git a/apps/web/src/components/views/avatars/RoomAvatar.tsx b/apps/web/src/components/views/avatars/RoomAvatar.tsx index dd1e5f0ce44..d7e9c126a6d 100644 --- a/apps/web/src/components/views/avatars/RoomAvatar.tsx +++ b/apps/web/src/components/views/avatars/RoomAvatar.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { useCallback, useMemo, type ComponentProps } from "react"; -import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix"; +import { type Room, RoomType, KnownMembership, EventType, RoomEvent } from "matrix-js-sdk/src/matrix"; import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types"; import BaseAvatar from "./BaseAvatar"; @@ -21,6 +21,7 @@ import { useSettingValue } from "../../../hooks/useSettings"; import { useRoomState } from "../../../hooks/useRoomState"; import { useRoomIdName } from "../../../hooks/room/useRoomIdName"; import { MediaPreviewValue } from "../../../@types/media_preview"; +import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; interface IProps extends Omit, "name" | "idName" | "url" | "onClick" | "size"> { // Room may be left unset here, but if it is, @@ -37,7 +38,8 @@ interface IProps extends Omit, "name" | "idNam } const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => { - const roomName = room?.name ?? oobData?.name ?? "?"; + const name = useTypedEventEmitterState(room, RoomEvent.Name, () => room?.name); + const roomName = name ?? oobData?.name ?? "?"; const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, "")); const roomIdName = useRoomIdName(room, oobData); diff --git a/apps/web/src/components/views/avatars/RoomAvatarView.tsx b/apps/web/src/components/views/avatars/RoomAvatarView.tsx index b21a0ed04e1..5fb25f8908e 100644 --- a/apps/web/src/components/views/avatars/RoomAvatarView.tsx +++ b/apps/web/src/components/views/avatars/RoomAvatarView.tsx @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { memo, type JSX } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; @@ -33,7 +33,7 @@ interface RoomAvatarViewProps { * Component to display the avatar of a room. * Currently only 32px size is supported. */ -export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { +export const RoomAvatarView = memo(function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { const vm = useRoomAvatarViewModel(room); // No decoration, we just show the avatar if (!vm.badgeDecoration) return ; @@ -54,7 +54,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { {label ? {icon} : icon} ); -} +}); /** * Get the decoration for the avatar based on the presence. diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index ad12d72ca9c..d8b9dd5f8f1 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -78,6 +78,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { private roomSkipList?: RoomSkipList; private readonly msc3946ProcessDynamicPredecessor: boolean; + /** + * Whether a batched LISTS_UPDATE_EVENT emission is pending. + * Used by {@link scheduleEmit} to coalesce rapid-fire updates into a single emit per frame. + */ + private pendingEmit = false; + public constructor(dispatcher: MatrixDispatcher) { super(dispatcher); this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); @@ -243,7 +249,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { newMembership === EffectiveMembership.Leave ) { this.roomSkipList.removeRoom(payload.room); - this.emit(LISTS_UPDATE_EVENT); + this.scheduleEmit(); return; } @@ -269,7 +275,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { case Action.AfterForgetRoom: { const room = payload.room; this.roomSkipList.removeRoom(room); - this.emit(LISTS_UPDATE_EVENT); + this.scheduleEmit(); + break; } } @@ -313,7 +320,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { break; } } - if (needsEmit) this.emit(LISTS_UPDATE_EVENT); + if (needsEmit) this.scheduleEmit(); } /** @@ -348,6 +355,20 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } } + /** + * Schedule a batched emission of LISTS_UPDATE_EVENT using requestAnimationFrame. + * Multiple calls within the same frame are coalesced into a single emit. + */ + private scheduleEmit(): void { + if (!this.pendingEmit) { + this.pendingEmit = true; + requestAnimationFrame(() => { + this.pendingEmit = false; + this.emit(LISTS_UPDATE_EVENT); + }); + } + } + /** * Add a room to the skiplist and emit an update. * @param room The room to add to the skiplist @@ -366,13 +387,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } else { this.roomSkipList.reInsertRoom(room); } - this.emit(LISTS_UPDATE_EVENT); + this.scheduleEmit(); } private onActiveSpaceChanged(): void { if (!this.roomSkipList) return; this.roomSkipList.calculateActiveSpaceForNodes(); - this.emit(LISTS_UPDATE_EVENT); + this.scheduleEmit(); } } diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index 73e154d7781..5b1f0909212 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -19,7 +19,6 @@ import type { RoomNotificationState } from "../../stores/notifications/RoomNotif import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; import { MessagePreviewStore } from "../../stores/message-preview"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; import DMRoomMap from "../../utils/DMRoomMap"; import SettingsStore from "../../settings/SettingsStore"; @@ -32,6 +31,7 @@ import { UIComponent } from "../../settings/UIFeature"; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications"; import { tagRoom } from "../../utils/room/tagRoom"; +import { keepIfSame } from "../../utils/keepIfSame"; import dispatcher from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; @@ -69,8 +69,12 @@ export class RoomListItemViewModel // Subscribe to notification state changes for this room this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged); - // Subscribe to message preview changes (will filter to this room) - this.disposables.trackListener(MessagePreviewStore.instance, UPDATE_EVENT, this.onMessagePreviewChanged); + // Subscribe to message preview changes for this specific room + this.disposables.trackListener( + MessagePreviewStore.instance, + MessagePreviewStore.getPreviewChangedEventName(props.room), + this.onMessagePreviewChanged, + ); // Subscribe to settings changes for message preview toggle const settingsWatchRef = SettingsStore.watchSetting( @@ -163,8 +167,12 @@ export class RoomListItemViewModel */ private updateItem(): void { const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState); - // Preserve message preview - it's managed separately by loadAndSetMessagePreview - this.snapshot.set({ ...newItem, messagePreview: this.snapshot.current.messagePreview }); + this.snapshot.merge({ + ...newItem, + notification: keepIfSame(this.snapshot.current.notification, newItem.notification), + // Preserve message preview - it's managed separately by loadAndSetMessagePreview + messagePreview: this.snapshot.current.messagePreview, + }); } private getMessagePreviewTag(): string { diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index d3a59a6d223..3ed45fc663b 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -452,8 +452,8 @@ export class RoomListViewModel isLoadingRooms, isRoomListEmpty, activeFilterId, - roomListState, - sections, + roomListState: keepIfSame(this.snapshot.current.roomListState, roomListState), + sections: keepIfSame(this.snapshot.current.sections, sections), }); } diff --git a/apps/web/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx b/apps/web/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx index a0ed6d6f266..7d2ad7cb854 100644 --- a/apps/web/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx +++ b/apps/web/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx @@ -6,7 +6,16 @@ */ import { renderHook, waitFor } from "jest-matrix-react"; -import { JoinRule, type MatrixClient, type Room, RoomMember, User } from "matrix-js-sdk/src/matrix"; +import { + JoinRule, + type MatrixClient, + MatrixEvent, + type Room, + RoomEvent, + RoomMember, + User, +} from "matrix-js-sdk/src/matrix"; +import { act } from "react"; import { AvatarBadgeDecoration, @@ -78,6 +87,7 @@ describe("RoomAvatarViewModel", () => { // 4. With presence, public room, video room and low priority, low priority takes precedence room.tags[DefaultTagID.LowPriority] = {}; + act(() => room.emit(RoomEvent.Tags, new MatrixEvent(), room)); rerender(room); expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority); }); diff --git a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index 1662fcbd04e..09d8f5ded04 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -43,6 +43,10 @@ describe("RoomListStoreV3", () => { } beforeEach(() => { + jest.spyOn(global, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home); jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home); jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve()); diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index 9806365e9a0..de023d4dda8 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -20,7 +20,6 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview"; -import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; import SettingsStore from "../../../src/settings/SettingsStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; @@ -200,7 +199,7 @@ describe("RoomListItemViewModel", () => { text: "Updated message", } as MessagePreview); - MessagePreviewStore.instance.emit(UPDATE_EVENT); + MessagePreviewStore.instance.emit(MessagePreviewStore.getPreviewChangedEventName(room)); await flushPromises(); expect(viewModel.getSnapshot().messagePreview).toBe("Updated message"); diff --git a/packages/shared-components/src/utils/VirtualizedList/virtualized-list.tsx b/packages/shared-components/src/utils/VirtualizedList/virtualized-list.tsx index 8bcf9372f6d..2324ec2b3d9 100644 --- a/packages/shared-components/src/utils/VirtualizedList/virtualized-list.tsx +++ b/packages/shared-components/src/utils/VirtualizedList/virtualized-list.tsx @@ -180,19 +180,12 @@ export function useVirtualizedList( /** Range of currently visible items in the viewport */ const [visibleRange, setVisibleRange] = useState(undefined); /** Map from item keys to their indices in the items array */ - const [keyToIndexMap, setKeyToIndexMap] = useState>(new Map()); - /** Whether the list is currently focused */ - const [isFocused, setIsFocused] = useState(false); - - // Update the key-to-index mapping whenever items change - useEffect(() => { - const newKeyToIndexMap = new Map(); - items.forEach((item, index) => { - const key = getItemKey(item); - newKeyToIndexMap.set(key, index); - }); - setKeyToIndexMap(newKeyToIndexMap); + const keyToIndexMap = useMemo(() => { + const map = new Map(); + items.forEach((item, index) => map.set(getItemKey(item), index)); + return map; }, [items, getItemKey]); + const [isFocused, setIsFocused] = useState(false); // Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed useEffect(() => {