Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/views/avatars/RoomAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
// Room may be left unset here, but if it is,
Expand All @@ -37,7 +38,8 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
}

const RoomAvatar: React.FC<IProps> = ({ 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);

Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/views/avatars/RoomAvatarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <RoomAvatar size="32px" room={room} />;
Expand All @@ -54,7 +54,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
{label ? <Tooltip label={label}>{icon}</Tooltip> : icon}
</Flex>
);
}
});

/**
* Get the decoration for the avatar based on the presence.
Expand Down
31 changes: 26 additions & 5 deletions apps/web/src/stores/room-list-v3/RoomListStoreV3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
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");
Expand Down Expand Up @@ -243,7 +249,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
newMembership === EffectiveMembership.Leave
) {
this.roomSkipList.removeRoom(payload.room);
this.emit(LISTS_UPDATE_EVENT);
this.scheduleEmit();
return;
}

Expand All @@ -269,7 +275,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
case Action.AfterForgetRoom: {
const room = payload.room;
this.roomSkipList.removeRoom(room);
this.emit(LISTS_UPDATE_EVENT);
this.scheduleEmit();

break;
}
}
Expand Down Expand Up @@ -313,7 +320,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
break;
}
}
if (needsEmit) this.emit(LISTS_UPDATE_EVENT);
if (needsEmit) this.scheduleEmit();
}

/**
Expand Down Expand Up @@ -348,6 +355,20 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
}
}

/**
* 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
Expand All @@ -366,13 +387,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
} 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();
}
}

Expand Down
18 changes: 13 additions & 5 deletions apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/viewmodels/room-list/RoomListViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,12 @@ export function useVirtualizedList<Item, Context>(
/** Range of currently visible items in the viewport */
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
/** Map from item keys to their indices in the items array */
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
/** Whether the list is currently focused */
const [isFocused, setIsFocused] = useState<boolean>(false);

// Update the key-to-index mapping whenever items change
useEffect(() => {
const newKeyToIndexMap = new Map<string, number>();
items.forEach((item, index) => {
const key = getItemKey(item);
newKeyToIndexMap.set(key, index);
});
setKeyToIndexMap(newKeyToIndexMap);
const keyToIndexMap = useMemo(() => {
const map = new Map<string, number>();
items.forEach((item, index) => map.set(getItemKey(item), index));
return map;
}, [items, getItemKey]);
const [isFocused, setIsFocused] = useState<boolean>(false);

// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
useEffect(() => {
Expand Down
Loading