Migrate the room list view to shared components (#31921)

* Add NotificationDecoration component

Add the NotificationDecoration component to shared-components.
This is a leaf component that renders notification badges and indicators
for rooms/items including mentions, unread counts, call indicators, etc.

* Add RoomListItem component

Add the RoomListItem component to shared-components.
Includes context menu, hover menu, notification menu, and more options menu.

* Add RoomListPrimaryFilters component

Add filter chips component for filtering the room list by
unread, people, rooms, favourites, mentions, invites, and low priority.

* Update VirtualizedList component

Update VirtualizedList to support the room list virtualization requirements.

* Add RoomList component

Add RoomList component that renders a virtualized list of room items.
Includes story mocks for testing.

* Add RoomListView component

Add RoomListView component that composes RoomList with filters,
empty states, and loading skeleton.

* Export room-list components from shared-components

Add exports for RoomListView, RoomListItem, RoomListPrimaryFilters, and RoomList.
Include i18n strings for room list components.

* Add RoomListItemViewModel

Add view model for individual room list items.
Manages per-room subscriptions and updates only when specific room data changes.

* Add RoomListViewViewModel

Add view model for the room list view.
Manages room list state, filtering, keyboard navigation, and child view models.

* Integrate shared components into RoomListView

Update RoomListView to use the new ViewModels and shared components.
Includes i18n string updates for element-web.

* Remove old room list implementation

Remove old ViewModels, hooks, and view components that are now
replaced by the shared-components implementation.

* Update sliding-sync playwright test

Update test expectations for new room list implementation.

* Add figma links

* Move viewModels to the right folder

* Rename to RoomListEmptyStateView

* Update VirtualizedRoomListView naming

* Update screenshots and snapshots

* Move viewmodel tests to the right location and fix some imports

* lint

* Use unknown as an Opaque type rather than any. It discourages property access within shared components and can still be cast back in EW.

* Update screenshots for new shared component rendering params

* Make room order tests deterministic
This commit is contained in:
David Langley
2026-02-05 21:05:14 +00:00
committed by GitHub
parent 6dba71a453
commit 6da1412de8
140 changed files with 20046 additions and 5950 deletions
@@ -1,57 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { type MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
interface MessagePreviewViewState {
/**
* A string representation of the message preview if available.
*/
message?: string;
}
/**
* View model for rendering a message preview for a given room list item.
* @param room The room for which we're rendering the message preview.
* @see {@link MessagePreviewViewState} for what this view model returns.
*/
export function useMessagePreviewViewModel(room: Room): MessagePreviewViewState {
const [messagePreview, setMessagePreview] = useState<MessagePreview | null>(null);
const updatePreview = useCallback(async (): Promise<void> => {
/**
* The second argument to getPreviewForRoom is a tag id which doesn't really make
* much sense within the context of the new room list. We can pass an empty string
* to match all tags for now but we should remember to actually change the implementation
* in the store once we remove the legacy room list.
*/
const newPreview = await MessagePreviewStore.instance.getPreviewForRoom(room, "");
setMessagePreview(newPreview);
}, [room]);
/**
* Update when the message preview has changed for this room.
*/
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
updatePreview();
});
/**
* Do an initial fetch of the message preview.
*/
useEffect(() => {
updatePreview();
}, [updatePreview]);
return {
message: messagePreview?.text,
};
}
@@ -1,226 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import DMRoomMap from "../../../utils/DMRoomMap";
import { DefaultTagID } from "../../../stores/room-list/models";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import dispatcher from "../../../dispatcher/dispatcher";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import PosthogTrackers from "../../../PosthogTrackers";
import { tagRoom } from "../../../utils/room/tagRoom";
import { RoomNotifState } from "../../../RoomNotifs";
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
export interface RoomListItemMenuViewState {
/**
* Whether the more options menu should be shown.
*/
showMoreOptionsMenu: boolean;
/**
* Whether the notification menu should be shown.
*/
showNotificationMenu: boolean;
/**
* Whether the room is a favourite room.
*/
isFavourite: boolean;
/**
* Whether the room is a low priority room.
*/
isLowPriority: boolean;
/**
* Can invite other user's in the room.
*/
canInvite: boolean;
/**
* Can copy the room link.
*/
canCopyRoomLink: boolean;
/**
* Can mark the room as read.
*/
canMarkAsRead: boolean;
/**
* Can mark the room as unread.
*/
canMarkAsUnread: boolean;
/**
* Whether the notification is set to all messages.
*/
isNotificationAllMessage: boolean;
/**
* Whether the notification is set to all messages loud.
*/
isNotificationAllMessageLoud: boolean;
/**
* Whether the notification is set to mentions and keywords only.
*/
isNotificationMentionOnly: boolean;
/**
* Whether the notification is muted.
*/
isNotificationMute: boolean;
/**
* Mark the room as read.
* @param evt
*/
markAsRead: (evt: Event) => void;
/**
* Mark the room as unread.
* @param evt
*/
markAsUnread: (evt: Event) => void;
/**
* Toggle the room as favourite.
* @param evt
*/
toggleFavorite: (evt: Event) => void;
/**
* Toggle the room as low priority.
*/
toggleLowPriority: () => void;
/**
* Invite other users in the room.
* @param evt
*/
invite: (evt: Event) => void;
/**
* Copy the room link in the clipboard.
* @param evt
*/
copyRoomLink: (evt: Event) => void;
/**
* Leave the room.
* @param evt
*/
leaveRoom: (evt: Event) => void;
/**
* Set the room notification state.
* @param state
*/
setRoomNotifState: (state: RoomNotifState) => void;
}
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const { level: notificationLevel } = useUnreadNotifications(room);
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
const canMarkAsRead = notificationLevel > NotificationLevel.None;
const canMarkAsUnread = !canMarkAsRead && !isArchived;
const canInvite =
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
const canCopyRoomLink = !isDm;
const [roomNotifState, setRoomNotifState] = useNotificationState(room);
const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages;
const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud;
const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly;
const isNotificationMute = roomNotifState === RoomNotifState.Mute;
// Actions
const markAsRead = useCallback(
async (evt: Event): Promise<void> => {
await clearRoomNotification(room, matrixClient);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt);
},
[room, matrixClient],
);
const markAsUnread = useCallback(
async (evt: Event): Promise<void> => {
await setMarkedUnreadState(room, matrixClient, true);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", evt);
},
[room, matrixClient],
);
const toggleFavorite = useCallback(
(evt: Event): void => {
tagRoom(room, DefaultTagID.Favourite);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
},
[room],
);
const toggleLowPriority = useCallback((): void => tagRoom(room, DefaultTagID.LowPriority), [room]);
const invite = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: "view_invite",
roomId: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", evt);
},
[room],
);
const copyRoomLink = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: "copy_room",
room_id: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
},
[room],
);
const leaveRoom = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: isArchived ? "forget_room" : "leave_room",
room_id: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", evt);
},
[room, isArchived],
);
return {
showMoreOptionsMenu,
showNotificationMenu,
isFavourite,
isLowPriority,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
isNotificationAllMessage,
isNotificationAllMessageLoud,
isNotificationMentionOnly,
isNotificationMute,
markAsRead,
markAsUnread,
toggleFavorite,
toggleLowPriority,
invite,
copyRoomLink,
leaveRoom,
setRoomNotifState,
};
}
@@ -1,250 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import { _t } from "../../../languageHandler";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { CallEvent, type ConnectionState } from "../../../models/Call";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import DMRoomMap from "../../../utils/DMRoomMap";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
export interface RoomListItemViewState {
/**
* The name of the room.
*/
name: string;
/**
* Whether the context menu should be shown.
*/
showContextMenu: boolean;
/**
* Whether the hover menu should be shown.
*/
showHoverMenu: boolean;
/**
* Open the room having given roomId.
*/
openRoom: () => void;
/**
* The a11y label for the room list item.
*/
a11yLabel: string;
/**
* The notification state of the room.
*/
notificationState: RoomNotificationState;
/**
* Whether the room should be bolded.
*/
isBold: boolean;
/**
* Whether the room is a video room
*/
isVideoRoom: boolean;
/**
* The connection state of the call.
* `null` if there is no call in the room.
*/
callConnectionState: ConnectionState | null;
/**
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
/**
* Whether the call is a voice or video call.
*/
callType: CallType | undefined;
/**
* Pre-rendered and translated preview for the latest message in the room, or undefined
* if no preview should be shown.
*/
messagePreview: string | undefined;
/**
* Whether the notification decoration should be shown.
*/
showNotificationDecoration: boolean;
}
/**
* View model for the room list item
* @see {@link RoomListItemViewState} for more information about what this view model returns.
*/
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const name = useEventEmitterState(room, RoomEvent.Name, () => room.name);
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState));
const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState(
getNotificationValues(notificationState),
);
useEffect(() => {
setA11yLabel(getA11yLabel(name, notificationState));
}, [name, notificationState]);
// Listen to changes in the notification state and update the values
useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => {
setA11yLabel(getA11yLabel(name, notificationState));
setNotificationValues(getNotificationValues(notificationState));
});
// If the notification reference change due to room change, update the values
useEffect(() => {
setNotificationValues(getNotificationValues(notificationState));
}, [notificationState]);
// We don't want to show the menus if
// - there is an invitation for this room
// - the user doesn't have access to notification and more options menus
const showContextMenu = !invited && hasAccessToOptionsMenu(room);
const showHoverMenu =
!invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
const messagePreview = useRoomMessagePreview(room);
// Video room
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
// EC video call or video room
const call = useCall(room.roomId);
const connectionState = useConnectionState(call);
const participantCount = useParticipantCount(call);
const callConnectionState = call ? connectionState : null;
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;
// Actions
const openRoom = useCallback((): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "RoomList",
});
}, [room]);
const [callType, setCallType] = useState<CallType>(CallType.Video);
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);
return {
name,
notificationState,
showContextMenu,
showHoverMenu,
openRoom,
a11yLabel,
isBold,
isVideoRoom,
callConnectionState,
hasParticipantInCall: participantCount > 0,
messagePreview,
showNotificationDecoration,
callType: call ? callType : undefined,
};
}
/**
* Calculate the values from the notification state
* @param notificationState
*/
function getNotificationValues(notificationState: RoomNotificationState): {
computeA11yLabel: (name: string) => string;
isBold: boolean;
invited: boolean;
hasVisibleNotification: boolean;
} {
const invited = notificationState.invited;
const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;
const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted;
return {
computeA11yLabel,
isBold,
invited,
hasVisibleNotification,
};
}
/**
* Get the a11y label for the room list item
* @param roomName
* @param notificationState
*/
function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string {
if (notificationState.isUnsentMessage) {
return _t("a11y|room_messsage_not_sent", {
roomName,
});
} else if (notificationState.invited) {
return _t("a11y|room_n_unread_invite", {
roomName,
});
} else if (notificationState.isMention) {
return _t("a11y|room_n_unread_messages_mentions", {
roomName,
count: notificationState.count,
});
} else if (notificationState.hasUnreadCount) {
return _t("a11y|room_n_unread_messages", {
roomName,
count: notificationState.count,
});
} else {
return _t("room_list|room|open_room", { roomName });
}
}
function useRoomMessagePreview(room: Room): string | undefined {
const { shouldShowMessagePreview } = useMessagePreviewToggle();
const [previewText, setPreviewText] = useState<string | undefined>(undefined);
const updatePreview = useCallback(async () => {
if (!shouldShowMessagePreview) {
setPreviewText(undefined);
return;
}
const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
// For the tag, we only care about whether the room is a DM or not as we don't show
// display names in previewsd for DMs, so anything else we just say is 'untagged'
// (even though it could actually be have other tags: we don't care about them).
const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom(
room,
roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged,
);
setPreviewText(messagePreview?.text);
}, [room, shouldShowMessagePreview]);
// MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
updatePreview();
});
useEffect(() => {
updatePreview();
}, [updatePreview]);
return previewText;
}
@@ -1,100 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { type PrimaryFilter, useFilteredRooms } from "./useFilteredRooms";
import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useStickyRoomList } from "./useStickyRoomList";
import { useRoomListNavigation } from "./useRoomListNavigation";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
export interface RoomListViewState {
/**
* Whether the list of rooms is being loaded.
*/
isLoadingRooms: boolean;
/**
* The room results to be displayed (along with the spaceId and filter keys at the time of query)
*/
roomsResult: RoomsResult;
/**
* Create a chat room
* @param e - The click event
*/
createChatRoom: () => void;
/**
* Whether the user can create a room in the current space
*/
canCreateRoom: boolean;
/**
* Create a room
* @param e - The click event
*/
createRoom: () => void;
/**
* A list of objects that provide the view enough information
* to render primary room filters.
*/
primaryFilters: PrimaryFilter[];
/**
* The currently active primary filter.
* If no primary filter is active, this will be undefined.
*/
activePrimaryFilter?: PrimaryFilter;
/**
* The index of the active room in the room list.
*/
activeIndex: number | undefined;
}
/**
* View model for the new room list
* @see {@link RoomListViewState} for more information about what this view model returns.
*/
export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext();
const { isLoadingRooms, primaryFilters, activePrimaryFilter, roomsResult: filteredRooms } = useFilteredRooms();
const { activeIndex, roomsResult } = useStickyRoomList(filteredRooms);
useRoomListNavigation(roomsResult.rooms);
const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => SpaceStore.instance.activeSpaceRoom,
);
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []);
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
return {
isLoadingRooms,
roomsResult,
canCreateRoom,
createRoom,
createChatRoom,
primaryFilters,
activePrimaryFilter,
activeIndex,
};
}
@@ -1,131 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td } from "../../../languageHandler";
import RoomListStoreV3, {
LISTS_LOADED_EVENT,
LISTS_UPDATE_EVENT,
type RoomsResult,
} from "../../../stores/room-list-v3/RoomListStoreV3";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
/**
* Provides information about a primary filter.
* A primary filter is a commonly used filter that is given
* more precedence in the UI. For eg, primary filters may be
* rendered as pills above the room list.
*/
export interface PrimaryFilter {
// A function to toggle this filter on and off.
toggle: () => void;
// Whether this filter is currently applied
active: boolean;
// Text that can be used in the UI to represent this filter.
name: string;
// The key of the filter
key: FilterKey;
}
interface FilteredRooms {
primaryFilters: PrimaryFilter[];
isLoadingRooms: boolean;
roomsResult: RoomsResult;
/**
* The currently active primary filter.
* If no primary filter is active, this will be undefined.
*/
activePrimaryFilter?: PrimaryFilter;
}
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
[FilterKey.MentionsFilter, _td("room_list|filters|mentions")],
[FilterKey.InvitesFilter, _td("room_list|filters|invites")],
[FilterKey.LowPriorityFilter, _td("room_list|filters|low_priority")],
]);
/**
* Track available filters and provide a filtered list of rooms.
*/
export function useFilteredRooms(): FilteredRooms {
/**
* Primary filter refers to the pill based filters
* rendered above the room list.
*/
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
const [roomsResult, setRoomsResult] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
setRoomsResult(newRooms);
}, []);
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[];
const getAppliedFilters = useCallback((): FilterKey[] => {
return filterUndefined([primaryFilter]);
}, [primaryFilter]);
useEffect(() => {
// Update the rooms state when the primary filter changes
const filters = getAppliedFilters();
updateRoomsFromStore(filters);
}, [getAppliedFilters, updateRoomsFromStore]);
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
const filters = getAppliedFilters();
updateRoomsFromStore(filters);
});
useEventEmitter(RoomListStoreV3.instance, LISTS_LOADED_EVENT, () => {
setIsLoadingRooms(false);
});
/**
* This tells the view which primary filters are available, how to toggle them
* and whether a given primary filter is active. @see {@link PrimaryFilter}
*/
const primaryFilters = useMemo(() => {
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
return {
toggle: () => {
setPrimaryFilter((currentFilter) => {
const filter = currentFilter === key ? undefined : key;
updateRoomsFromStore(filterUndefined([filter]));
return filter;
});
},
active: primaryFilter === key,
name,
key,
};
};
const filters: PrimaryFilter[] = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
filters.push(createPrimaryFilter(key, _t(name)));
}
return filters;
}, [primaryFilter, updateRoomsFromStore]);
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
return {
isLoadingRooms,
primaryFilters,
activePrimaryFilter,
roomsResult,
};
}
@@ -1,32 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import { useSettingValue } from "../../../hooks/useSettings";
interface MessagePreviewToggleState {
shouldShowMessagePreview: boolean;
toggleMessagePreview: () => void;
}
/**
* This hook:
* - Provides a state that tracks whether message previews are turned on or off.
* - Provides a function to toggle message previews.
*/
export function useMessagePreviewToggle(): MessagePreviewToggleState {
const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview");
const toggleMessagePreview = useCallback((): void => {
const toggled = !shouldShowMessagePreview;
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
}, [shouldShowMessagePreview]);
return { toggleMessagePreview, shouldShowMessagePreview };
}
@@ -1,56 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { Action } from "../../../dispatcher/actions";
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
/**
* Hook to navigate the room list using keyboard shortcuts.
* It listens to the ViewRoomDelta action and updates the room list accordingly.
* @param rooms
*/
export function useRoomListNavigation(rooms: Room[]): void {
useDispatcher(dispatcher, (payload) => {
if (payload.action !== Action.ViewRoomDelta) return;
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
if (!roomId) return;
const { delta, unread } = payload as ViewRoomDeltaPayload;
const filteredRooms = unread
? // Filter the rooms to only include unread ones and the active room
rooms.filter((room) => {
const state = RoomNotificationStateStore.instance.getRoomState(room);
return room.roomId === roomId || state.isUnread;
})
: rooms;
const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId);
if (currentIndex === -1) return;
// Get the next/previous new room according to the delta
// Use slice to loop on the list
// If delta is -1 at the start of the list, it will go to the end
// If delta is 1 at the end of the list, it will go to the start
const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length);
if (!newRoom) return;
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: newRoom.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
});
});
}
@@ -1,138 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
function getIndexByRoomId(rooms: Room[], roomId: string): number | undefined {
const index = rooms.findIndex((room) => room.roomId === roomId);
return index === -1 ? undefined : index;
}
function getRoomsWithStickyRoom(
rooms: Room[],
oldIndex: number | undefined,
newIndex: number | undefined,
isRoomChange: boolean,
): { newRooms: Room[]; newIndex: number | undefined } {
const updated = { newIndex, newRooms: rooms };
if (isRoomChange) {
/*
* When opening another room, the index should obviously change.
*/
return updated;
}
if (newIndex === undefined || oldIndex === undefined) {
/*
* If oldIndex is undefined, then there was no active room before.
* So nothing to do in regards to sticky room.
* Similarly, if newIndex is undefined, there's no active room anymore.
*/
return updated;
}
if (newIndex === oldIndex) {
/*
* If the index hasn't changed, we have nothing to do.
*/
return updated;
}
if (oldIndex > rooms.length - 1) {
/*
* If the old index falls out of the bounds of the rooms array
* (usually because rooms were removed), we can no longer place
* the active room in the same old index.
*/
return updated;
}
/*
* Making the active room sticky is as simple as removing it from
* its new index and placing it in the old index.
*/
const newRooms = [...rooms];
const [newRoom] = newRooms.splice(newIndex, 1);
newRooms.splice(oldIndex, 0, newRoom);
return { newIndex: oldIndex, newRooms };
}
export interface StickyRoomListResult {
/**
* The rooms result with the active sticky room applied
*/
roomsResult: RoomsResult;
/**
* Index of the active room in the room list.
*/
activeIndex: number | undefined;
}
/**
* - Provides a list of rooms such that the active room is sticky i.e the active room is kept
* in the same index even when the order of rooms in the list changes.
* - Provides the index of the active room.
* @param rooms list of rooms
* @see {@link StickyRoomListResult} details what this hook returns..
*/
export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult {
const [listState, setListState] = useState<StickyRoomListResult>({
activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()!),
roomsResult: roomsResult,
});
const currentSpaceRef = useRef(SpaceStore.instance.activeSpace);
const updateRoomsAndIndex = useCallback(
(newRoomId: string | null, isRoomChange: boolean = false) => {
setListState((current) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId!);
const oldIndex = current.activeIndex;
const { newIndex, newRooms } = getRoomsWithStickyRoom(
roomsResult.rooms,
oldIndex,
newActiveIndex,
isRoomChange,
);
return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } };
});
},
[roomsResult],
);
// Re-calculate the index when the active room has changed.
useDispatcher(dispatcher, (payload) => {
if (payload.action === Action.ActiveRoomChanged) updateRoomsAndIndex(payload.newRoomId, true);
});
// Re-calculate the index when the list of rooms has changed.
useEffect(() => {
let newRoomId: string | null = null;
let isRoomChange = false;
if (currentSpaceRef.current !== roomsResult.spaceId) {
/*
If the space has changed, we check if we can immediately set the active
index to the last opened room in that space. Otherwise, we might see a
flicker because of the delay between the space change event and
active room change dispatch.
*/
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(roomsResult.spaceId);
isRoomChange = true;
currentSpaceRef.current = roomsResult.spaceId;
}
updateRoomsAndIndex(newRoomId, isRoomChange);
}, [roomsResult, updateRoomsAndIndex]);
return listState;
}
@@ -1,182 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type PropsWithChildren } from "react";
import { Button } from "@vector-im/compound-web";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import { Flex } from "@element-hq/web-shared-components";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";
interface EmptyRoomListProps {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The empty state for the room list
*/
export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefined {
// If there is no active primary filter, show the default empty state
if (!vm.activePrimaryFilter) return <DefaultPlaceholder vm={vm} />;
switch (vm.activePrimaryFilter.key) {
case FilterKey.FavouriteFilter:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_favourites")}
description={_t("room_list|empty|no_favourites_description")}
/>
);
case FilterKey.PeopleFilter:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_people")}
description={_t("room_list|empty|no_people_description")}
/>
);
case FilterKey.RoomsFilter:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_rooms")}
description={_t("room_list|empty|no_rooms_description")}
/>
);
case FilterKey.UnreadFilter:
return (
<ActionPlaceholder
title={_t("room_list|empty|no_unread")}
action={_t("room_list|empty|show_chats")}
filter={vm.activePrimaryFilter}
/>
);
case FilterKey.InvitesFilter:
return (
<ActionPlaceholder
title={_t("room_list|empty|no_invites")}
action={_t("room_list|empty|show_activity")}
filter={vm.activePrimaryFilter}
/>
);
case FilterKey.MentionsFilter:
return (
<ActionPlaceholder
title={_t("room_list|empty|no_mentions")}
action={_t("room_list|empty|show_activity")}
filter={vm.activePrimaryFilter}
/>
);
case FilterKey.LowPriorityFilter:
return (
<ActionPlaceholder
title={_t("room_list|empty|no_lowpriority")}
action={_t("room_list|empty|show_activity")}
filter={vm.activePrimaryFilter}
/>
);
default:
return undefined;
}
}
interface GenericPlaceholderProps {
/**
* The title of the placeholder
*/
title: string;
/**
* The description of the placeholder
*/
description?: string;
}
/**
* A generic placeholder for the room list
*/
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
return (
<Flex
data-testid="empty-room-list"
className="mx_EmptyRoomList_GenericPlaceholder"
direction="column"
align="stretch"
justify="center"
gap="var(--cpd-space-2x)"
>
<span className="mx_EmptyRoomList_GenericPlaceholder_title">{title}</span>
{description && <span className="mx_EmptyRoomList_GenericPlaceholder_description">{description}</span>}
{children}
</Flex>
);
}
interface DefaultPlaceholderProps {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The default empty state for the room list when no primary filter is active
* The user can create chat or room (if they have the permission)
*/
function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
return (
<GenericPlaceholder
title={_t("room_list|empty|no_chats")}
description={
vm.canCreateRoom
? _t("room_list|empty|no_chats_description")
: _t("room_list|empty|no_chats_description_no_room_rights")
}
>
<Flex
className="mx_EmptyRoomList_DefaultPlaceholder"
align="center"
justify="center"
direction="column"
gap="var(--cpd-space-4x)"
>
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
{_t("action|start_chat")}
</Button>
{vm.canCreateRoom && (
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
{_t("action|new_room")}
</Button>
)}
</Flex>
</GenericPlaceholder>
);
}
interface ActionPlaceholderProps {
filter: PrimaryFilter;
title: string;
action: string;
}
/**
* A placeholder for the room list when a filter is active
* The user can take action to toggle the filter
*/
function ActionPlaceholder({ filter, title, action }: ActionPlaceholderProps): JSX.Element {
return (
<GenericPlaceholder title={title}>
<Button kind="tertiary" onClick={filter.toggle}>
{action}
</Button>
</GenericPlaceholder>
);
}
@@ -1,144 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useRef, type JSX, useMemo } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { isEqual } from "lodash";
import {
type VirtualizedListContext,
VirtualizedList,
type ScrollIntoViewOnChange,
} from "@element-hq/web-shared-components";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView";
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
interface RoomListProps {
/**
* The view model state for the room list.
*/
vm: RoomListViewState;
}
type Context = {
spaceId: string;
filterKeys: FilterKey[] | undefined;
};
/**
* Height of a single room list item
*/
const ROOM_LIST_ITEM_HEIGHT = 48;
/**
* Amount to extend the top and bottom of the viewport by.
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space when using the mouse wheel,
* and the trackpad scrolling at a slow to moderate speed where you can still see/read the content.
* Using the trackpad to sling through a large percentage of the list quickly will still show blank space.
* We would likely need to simplify the item content to improve this case.
*/
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
/**
* A virtualized list of rooms.
*/
export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
const getItemComponent = useCallback(
(
index: number,
item: Room,
context: VirtualizedListContext<Context>,
onFocus: (item: Room, e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = item.roomId;
const isRovingItem = itemKey === context.tabIndexKey;
const isFocused = isRovingItem && context.focused;
const isSelected = activeIndex === index;
return (
<RoomListItemView
room={item}
key={itemKey}
isSelected={isSelected}
isFocused={isFocused}
tabIndex={isRovingItem ? 0 : -1}
roomIndex={index}
roomCount={roomCount}
onFocus={onFocus}
/>
);
},
[activeIndex, roomCount],
);
const getItemKey = useCallback((item: Room): string => {
return item.roomId;
}, []);
const scrollIntoViewOnChange = useCallback<ScrollIntoViewOnChange<Room, Context>>(
(params) => {
const { spaceId, filterKeys } = params.context.context;
const shouldScrollIndexIntoView =
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
lastFilterKeys.current = filterKeys;
lastSpaceId.current = spaceId;
if (shouldScrollIndexIntoView) {
return {
align: `start`,
index: activeIndex || 0,
behavior: "auto",
};
}
return false;
},
[activeIndex],
);
const keyDownCallback = useCallback((ev: React.KeyboardEvent) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
}, []);
const context = useMemo<Context>(
() => ({ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }),
[roomsResult.spaceId, roomsResult.filterKeys],
);
return (
<VirtualizedList
context={context}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
items={roomsResult.rooms}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
onKeyDown={keyDownCallback}
increaseViewportBy={{
bottom: EXTENDED_VIEWPORT_HEIGHT,
top: EXTENDED_VIEWPORT_HEIGHT,
}}
/>
);
}
@@ -1,44 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { type JSX, type PropsWithChildren } from "react";
import { ContextMenu } from "@vector-im/compound-web";
import React from "react";
import { _t } from "../../../../languageHandler";
import { MoreOptionContent } from "./RoomListItemMenuView";
import { useRoomListItemMenuViewModel } from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
interface RoomListItemContextMenuViewProps {
/**
* The room to display the menu for.
*/
room: Room;
}
/**
* A view for the room list item context menu.
*/
export function RoomListItemContextMenuView({
room,
children,
}: PropsWithChildren<RoomListItemContextMenuViewProps>): JSX.Element {
const vm = useRoomListItemMenuViewModel(room);
return (
<ContextMenu
title={_t("room_list|room|more_options")}
showTitle={false}
// To not mess with the roving tab index of the button
hasAccessibleAlternative={true}
trigger={children}
>
<MoreOptionContent vm={vm} />
</ContextMenu>
);
}
@@ -1,242 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useState } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem } from "@vector-im/compound-web";
import {
MarkAsReadIcon,
MarkAsUnreadIcon,
FavouriteIcon,
ArrowDownIcon,
UserAddIcon,
LinkIcon,
LeaveIcon,
OverflowHorizontalIcon,
NotificationsSolidIcon,
NotificationsOffSolidIcon,
CheckIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { type Room } from "matrix-js-sdk/src/matrix";
import { Flex } from "@element-hq/web-shared-components";
import classNames from "classnames";
import { _t } from "../../../../languageHandler";
import {
type RoomListItemMenuViewState,
useRoomListItemMenuViewModel,
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
import { RoomNotifState } from "../../../../RoomNotifs";
interface RoomListItemMenuViewProps {
/**
* Additional class name for the root element.
*/
className?: string;
/**
* The room to display the menu for.
*/
room: Room;
}
/**
* A view for the room list item menu.
*/
export function RoomListItemMenuView({ room, className }: RoomListItemMenuViewProps): JSX.Element {
const vm = useRoomListItemMenuViewModel(room);
return (
<Flex className={classNames("mx_RoomListItemMenuView", className)} align="center" gap="var(--cpd-space-1x)">
{vm.showMoreOptionsMenu && <MoreOptionsMenu vm={vm} />}
{vm.showNotificationMenu && <NotificationMenu vm={vm} />}
</Flex>
);
}
interface MoreOptionsMenuProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
}
/**
* The more options menu for the room list item.
*/
function MoreOptionsMenu({ vm }: MoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={
<IconButton
tooltip={_t("room_list|room|more_options")}
aria-label={_t("room_list|room|more_options")}
size="24px"
>
<OverflowHorizontalIcon />
</IconButton>
}
>
<MoreOptionContent vm={vm} />
</Menu>
);
}
interface MoreOptionContentProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
}
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
return (
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
{vm.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
label={_t("room_list|more_options|mark_read")}
onSelect={vm.markAsRead}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{vm.canMarkAsUnread && (
<MenuItem
Icon={MarkAsUnreadIcon}
label={_t("room_list|more_options|mark_unread")}
onSelect={vm.markAsUnread}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<ToggleMenuItem
checked={vm.isFavourite}
Icon={FavouriteIcon}
label={_t("room_list|more_options|favourited")}
onSelect={vm.toggleFavorite}
onClick={(evt) => evt.stopPropagation()}
/>
<ToggleMenuItem
checked={vm.isLowPriority}
Icon={ArrowDownIcon}
label={_t("room_list|more_options|low_priority")}
onSelect={vm.toggleLowPriority}
onClick={(evt) => evt.stopPropagation()}
/>
{vm.canInvite && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={vm.invite}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{vm.canCopyRoomLink && (
<MenuItem
Icon={LinkIcon}
label={_t("room_list|more_options|copy_link")}
onSelect={vm.copyRoomLink}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<Separator />
<MenuItem
kind="critical"
Icon={LeaveIcon}
label={_t("room_list|more_options|leave_room")}
onSelect={vm.leaveRoom}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
</div>
);
}
interface NotificationMenuProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
}
function NotificationMenu({ vm }: NotificationMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={
<IconButton
size="24px"
tooltip={_t("room_list|notification_options")}
aria-label={_t("room_list|notification_options")}
>
{vm.isNotificationMute ? <NotificationsOffSolidIcon /> : <NotificationsSolidIcon />}
</IconButton>
}
>
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<MenuItem
aria-selected={vm.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMute && checkComponent}
</MenuItem>
</div>
</Menu>
);
}
@@ -1,124 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, useEffect, useRef } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Flex } from "@element-hq/web-shared-components";
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
/**
* The room to display
*/
room: Room;
/**
* Whether the room is selected
*/
isSelected: boolean;
/**
* Whether the room is focused
*/
isFocused: boolean;
/**
* A callback that indicates the item has received focus
*/
onFocus: (room: Room, e: React.FocusEvent) => void;
/**
* The index of the room in the list
*/
roomIndex: number;
/**
* The total number of rooms in the list
*/
roomCount: number;
}
/**
* An item in the room list
*/
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
isFocused,
onFocus,
roomIndex: index,
roomCount: count,
...props
}: RoomListItemViewProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
const vm = useRoomListItemViewModel(room);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true });
}
}, [isFocused]);
const content = (
<Flex
as="button"
ref={ref}
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_has_menu: vm.showHoverMenu,
mx_RoomListItemView_selected: isSelected,
mx_RoomListItemView_bold: vm.isBold,
})}
gap="var(--cpd-space-3x)"
align="center"
type="button"
role="option"
aria-posinset={index + 1}
aria-setsize={count}
aria-selected={isSelected}
aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()}
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(room, e)}
tabIndex={isFocused ? 0 : -1}
{...props}
>
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-2x)"
align="center"
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className="mx_RoomListItemView_text">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
</div>
)}
</div>
{vm.showHoverMenu && <RoomListItemMenuView className="mx_RoomListItemView_menu" room={room} />}
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
{vm.showNotificationDecoration && (
<NotificationDecoration
className="mx_RoomListItemView_notificationDecoration"
notificationState={vm.notificationState}
aria-hidden={true}
callType={vm.callType}
/>
)}
</Flex>
</Flex>
);
if (!vm.showContextMenu) return content;
return <RoomListItemContextMenuView room={room}>{content}</RoomListItemContextMenuView>;
});
@@ -1,169 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useEffect, useId, useRef, useState, type RefObject } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { Flex } from "@element-hq/web-shared-components";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
interface RoomListPrimaryFiltersProps {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The primary filters for the room list
*/
export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element {
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);
const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters<HTMLUListElement>(isExpanded);
const filters = useVisibleFilters(vm.primaryFilters, wrappingIndex);
return (
<Flex
className="mx_RoomListPrimaryFilters"
data-testid="primary-filters"
gap="var(--cpd-space-3x)"
direction="row-reverse"
justify="space-between"
>
{displayChevron && (
<IconButton
kind="secondary"
aria-expanded={isExpanded}
aria-controls={id}
className="mx_RoomListPrimaryFilters_IconButton"
aria-label={isExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")}
size="28px"
onClick={() => setIsExpanded((_expanded) => !_expanded)}
>
<ChevronDownIcon />
</IconButton>
)}
<Flex
id={id}
as="div"
role="listbox"
aria-label={_t("room_list|primary_filters")}
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
className="mx_RoomListPrimaryFilters_list"
ref={ref}
>
{filters.map((filter, i) => (
<ChatFilter key={i} role="option" selected={filter.active} onClick={() => filter.toggle()}>
{filter.name}
</ChatFilter>
))}
</Flex>
</Flex>
);
}
/**
* A hook to manage the wrapping of filters in the room list.
* It observes the filter list and hides filters that are wrapping when the list is not expanded.
* @param isExpanded
* @returns an object containing:
* - `ref`: a ref to put on the filter list element
* - `isWrapping`: a boolean indicating if the filters are wrapping
* - `wrappingIndex`: the index of the first filter that is wrapping
*/
function useCollapseFilters<T extends HTMLElement>(
isExpanded: boolean,
): { ref: RefObject<T | null>; isWrapping: boolean; wrappingIndex: number } {
const ref = useRef<T>(null);
const [isWrapping, setIsWrapping] = useState(false);
const [wrappingIndex, setWrappingIndex] = useState(-1);
useEffect(() => {
if (!ref.current) return;
const hideFilters = (list: Element): void => {
let isWrapping = false;
Array.from(list.children).forEach((node, i): void => {
const child = node as HTMLElement;
const wrappingClass = "mx_RoomListPrimaryFilters_wrapping";
child.setAttribute("aria-hidden", "false");
child.classList.remove(wrappingClass);
// If the filter list is expanded, all filters are visible
if (isExpanded) return;
// If the previous element is on the left element of the current one, it means that the filter is wrapping
const previousSibling = child.previousElementSibling as HTMLElement | null;
if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) {
if (!isWrapping) setWrappingIndex(i);
isWrapping = true;
}
// If the filter is wrapping, we hide it
child.classList.toggle(wrappingClass, isWrapping);
child.setAttribute("aria-hidden", isWrapping.toString());
});
if (!isWrapping) setWrappingIndex(-1);
setIsWrapping(isExpanded || isWrapping);
};
hideFilters(ref.current);
const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target)));
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [isExpanded]);
return { ref, isWrapping, wrappingIndex };
}
/**
* A hook to sort the filters by active state.
* The list is sorted if the current filter index is greater than or equal to the wrapping index.
* If the wrapping index is -1, the filters are not sorted.
*
* @param filters - the list of filters to sort.
* @param wrappingIndex - the index of the first filter that is wrapping.
*/
export function useVisibleFilters(
filters: RoomListViewState["primaryFilters"],
wrappingIndex: number,
): RoomListViewState["primaryFilters"] {
// By default, the filters are not sorted
const [sortedFilters, setSortedFilters] = useState(filters);
useEffect(() => {
const isActiveFilterWrapping = filters.findIndex((f) => f.active) >= wrappingIndex;
// If the active filter is not wrapping, we don't need to sort the filters
if (!isActiveFilterWrapping || wrappingIndex === -1) {
setSortedFilters(filters);
return;
}
// Sort the filters with the current filter at first position
setSortedFilters(
filters.slice().sort((filterA, filterB) => {
// If the filter is active, it should be at the top of the list
if (filterA.active && !filterB.active) return -1;
if (!filterA.active && filterB.active) return 1;
// If both filters are active or not, keep their original order
return 0;
}),
);
}, [filters, wrappingIndex]);
return sortedFilters;
}
@@ -5,33 +5,47 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { useCallback, type JSX, type ReactNode } from "react";
import {
RoomListView as SharedRoomListView,
useCreateAutoDisposedViewModel,
type Room as SharedRoom,
} from "@element-hq/web-shared-components";
import { type Room } from "matrix-js-sdk/src/matrix";
import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "./RoomList";
import { EmptyRoomList } from "./EmptyRoomList";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
import { RoomListViewViewModel } from "../../../../viewmodels/room-list/RoomListViewViewModel";
/**
* Host the room list and the (future) room filters
* RoomListView component using shared components with proper MVVM pattern.
*/
export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel();
const isRoomListEmpty = vm.roomsResult.rooms.length === 0;
let listBody;
if (vm.isLoadingRooms) {
listBody = <div className="mx_RoomListSkeleton" />;
} else if (isRoomListEmpty) {
listBody = <EmptyRoomList vm={vm} />;
} else {
listBody = <RoomList vm={vm} />;
}
return (
<>
<div>
<RoomListPrimaryFilters vm={vm} />
</div>
{listBody}
</>
);
const matrixClient = useMatrixClientContext();
// Create and auto-dispose ViewModel instance
const vm = useCreateAutoDisposedViewModel(() => new RoomListViewViewModel({ client: matrixClient }));
// Render avatar for each room - memoized to prevent re-renders
const renderAvatar = useCallback((room: SharedRoom): ReactNode => {
return <RoomAvatarView room={room as Room} />;
}, []);
// Handle keyboard navigation for landmarks
const onKeyDown = useCallback((ev: React.KeyboardEvent) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
}
}, []);
return <SharedRoomListView vm={vm} renderAvatar={renderAvatar} onKeyDown={onKeyDown} />;
}
+2 -52
View File
@@ -12,16 +12,6 @@
"other": "%(count)s unread messages including mentions."
},
"recent_rooms": "Recent rooms",
"room_messsage_not_sent": "Open room %(roomName)s with an unsent message.",
"room_n_unread_invite": "Open room %(roomName)s invitation.",
"room_n_unread_messages": {
"one": "Open room %(roomName)s with 1 unread message.",
"other": "Open room %(roomName)s with %(count)s unread messages."
},
"room_n_unread_messages_mentions": {
"one": "Open room %(roomName)s with 1 unread mention.",
"other": "Open room %(roomName)s with %(count)s unread messages including mentions."
},
"room_name": "Room %(name)s",
"room_status_bar": "Room status bar",
"seek_bar_label": "Audio seek bar",
@@ -1717,7 +1707,6 @@
"class_global": "Global",
"class_other": "Other",
"default": "Default",
"default_settings": "Match default settings",
"email_pusher_app_display_name": "Email Notifications",
"enable_prompt_toast_description": "Enable desktop notifications",
"enable_prompt_toast_title": "Notifications",
@@ -1736,8 +1725,7 @@
"mentions_and_keywords_description": "Get notified only with mentions and keywords as set up in your <a>settings</a>",
"mentions_keywords": "Mentions and keywords",
"message_didnt_send": "Message didn't send. Click for info.",
"mute_description": "You won't get any notifications",
"mute_room": "Mute room"
"mute_description": "You won't get any notifications"
},
"notifier": {
"m.key.verification.request": "%(name)s is requesting verification"
@@ -2154,37 +2142,9 @@
"add_space_label": "Add space",
"breadcrumbs_empty": "No recently visited rooms",
"breadcrumbs_label": "Recently visited rooms",
"collapse_filters": "Collapse filter list",
"empty": {
"no_chats": "No chats yet",
"no_chats_description": "Get started by messaging someone or by creating a room",
"no_chats_description_no_room_rights": "Get started by messaging someone",
"no_favourites": "You don't have favourite chats yet",
"no_favourites_description": "You can add a chat to your favourites in the chat settings",
"no_invites": "You don't have any unread invites",
"no_lowpriority": "You don't have any low priority rooms",
"no_mentions": "You don't have any unread mentions",
"no_people": "You dont have direct chats with anyone yet",
"no_people_description": "You can deselect filters in order to see your other chats",
"no_rooms": "Youre not in any room yet",
"no_rooms_description": "You can deselect filters in order to see your other chats",
"no_unread": "Congrats! You dont have any unread messages",
"show_activity": "See all activity",
"show_chats": "Show all chats"
},
"expand_filters": "Expand filter list",
"failed_add_tag": "Failed to add tag %(tagName)s to room",
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
"failed_set_dm_tag": "Failed to set direct message tag",
"filters": {
"favourite": "Favourites",
"invites": "Invites",
"low_priority": "Low priority",
"mentions": "Mentions",
"people": "People",
"rooms": "Rooms",
"unread": "Unreads"
},
"home_menu_label": "Home options",
"join_public_room_label": "Join public room",
"joining_rooms_status": {
@@ -2193,23 +2153,13 @@
},
"list_title": "Room list",
"more_options": {
"copy_link": "Copy room link",
"favourited": "Favourited",
"leave_room": "Leave room",
"low_priority": "Low priority",
"mark_read": "Mark as read",
"mark_unread": "Mark as unread"
"leave_room": "Leave room"
},
"notification_options": "Notification options",
"primary_filters": "Room list filters",
"redacting_messages_status": {
"one": "Currently removing messages in %(count)s room",
"other": "Currently removing messages in %(count)s rooms"
},
"room": {
"more_options": "More Options",
"open_room": "Open room %(roomName)s"
},
"show_less": "Show less",
"show_n_more": {
"one": "Show %(count)s more",
@@ -26,11 +26,11 @@ import {
showSpaceSettings,
} from "../../utils/space";
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { createRoom, hasCreateRoomRights } from "../../components/viewmodels/roomlist/utils";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../stores/room-list-v3/skip-list/sorters";
import { SettingLevel } from "../../settings/SettingLevel";
import { createRoom, hasCreateRoomRights } from "./utils";
export interface Props {
/**
@@ -0,0 +1,327 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
BaseViewModel,
RoomNotifState,
type RoomListItemSnapshot,
type RoomListItemActions,
} from "@element-hq/web-shared-components";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import type { Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
import { MessagePreviewStore } from "../../stores/room-list/MessagePreviewStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { DefaultTagID } from "../../stores/room-list/models";
import DMRoomMap from "../../utils/DMRoomMap";
import SettingsStore from "../../settings/SettingsStore";
import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import { EchoChamber } from "../../stores/local-echo/EchoChamber";
import { RoomNotifState as ElementRoomNotifState } from "../../RoomNotifs";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications";
import { tagRoom } from "../../utils/room/tagRoom";
import dispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../PosthogTrackers";
interface RoomItemProps {
room: Room;
client: MatrixClient;
}
/**
* View model for an individual room list item.
* Manages per-room subscriptions and updates only when this specific room's data changes.
* Implements RoomListItemActions to provide interaction callbacks.
*/
export class RoomListItemViewModel
extends BaseViewModel<RoomListItemSnapshot, RoomItemProps>
implements RoomListItemActions
{
private notifState: RoomNotificationState;
public constructor(props: RoomItemProps) {
// Get notification state first so we can generate a complete initial snapshot
const notifState = RoomNotificationStateStore.instance.getRoomState(props.room);
const initialItem = RoomListItemViewModel.generateItemSync(props.room, props.client, notifState);
super(props, initialItem);
this.notifState = notifState;
// 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 settings changes for message preview toggle
const settingsWatchRef = SettingsStore.watchSetting(
"RoomList.showMessagePreview",
null,
this.onMessagePreviewSettingChanged,
);
this.disposables.track(() => {
SettingsStore.unwatchSetting(settingsWatchRef);
});
// Subscribe to call state changes
this.disposables.trackListener(CallStore.instance, CallStoreEvent.ConnectedCalls, this.onCallStateChanged);
// Subscribe to room-specific events
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
// Load message preview asynchronously (sync data is already complete)
void this.loadAndSetMessagePreview();
}
private onNotificationChanged = (): void => {
this.updateItem();
};
private onMessagePreviewChanged = (): void => {
void this.loadAndSetMessagePreview();
};
private onMessagePreviewSettingChanged = (): void => {
void this.loadAndSetMessagePreview();
};
private onCallStateChanged = (): void => {
// Only update if call state for this room actually changed
const call = CallStore.instance.getCall(this.props.room.roomId);
const currentCallType = this.snapshot.current.notification.callType;
const newCallType =
call && call.participants.size > 0 ? (call.callType === CallType.Voice ? "voice" : "video") : undefined;
if (currentCallType !== newCallType) {
this.updateItem();
}
};
private onRoomChanged = (): void => {
this.updateItem();
};
/**
* Update the item snapshot with current sync data.
* Preserves the message preview which is managed separately.
*/
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 });
}
private getMessagePreviewTag(): string {
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId));
return isDm ? DefaultTagID.DM : DefaultTagID.Untagged;
}
/**
* Load the message preview for this room if enabled.
* Returns undefined if previews are disabled or couldn't be loaded.
*/
private async loadMessagePreview(): Promise<string | undefined> {
const shouldShowMessagePreview = SettingsStore.getValue("RoomList.showMessagePreview");
if (!shouldShowMessagePreview) {
return undefined;
}
const messagePreviewTag = this.getMessagePreviewTag();
const preview = await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, messagePreviewTag);
return preview?.text;
}
/**
* Load and set the message preview if it differs from current.
*/
private async loadAndSetMessagePreview(): Promise<void> {
const messagePreview = await this.loadMessagePreview();
if (messagePreview !== this.snapshot.current.messagePreview) {
this.snapshot.merge({ messagePreview });
}
}
/**
* Generate a complete RoomListItem with all synchronous data.
* Message preview is loaded separately to avoid blocking initial render.
*/
private static generateItemSync(
room: Room,
client: MatrixClient,
notifState: RoomNotificationState,
): RoomListItemSnapshot {
// Get room tags for menu state
const roomTags = room.tags;
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
// Message preview will be loaded asynchronously and updated separately
const messagePreview = undefined;
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
// More options menu state
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const showNotificationMenu = hasAccessToNotificationMenu(room, client.isGuest(), isArchived);
// Notification levels
const canMarkAsRead = notifState.level > NotificationLevel.None;
const canMarkAsUnread = !canMarkAsRead && !isArchived;
const canInvite = room.canInvite(client.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
const canCopyRoomLink = !isDm;
// Get the current room notification state from EchoChamber
const echoChamber = EchoChamber.forRoom(room);
const elementRoomNotifState = echoChamber.notificationVolume;
// Convert element-web RoomNotifState to shared-components RoomNotifState
let roomNotifState: RoomNotifState;
switch (elementRoomNotifState) {
case ElementRoomNotifState.AllMessages:
roomNotifState = RoomNotifState.AllMessages;
break;
case ElementRoomNotifState.AllMessagesLoud:
roomNotifState = RoomNotifState.AllMessagesLoud;
break;
case ElementRoomNotifState.MentionsOnly:
roomNotifState = RoomNotifState.MentionsOnly;
break;
case ElementRoomNotifState.Mute:
roomNotifState = RoomNotifState.Mute;
break;
default:
roomNotifState = RoomNotifState.AllMessages;
}
const isNotificationMute = elementRoomNotifState === ElementRoomNotifState.Mute;
// Video room and call state tracking
const call = CallStore.instance.getCall(room.roomId);
const participantCount = call?.participants.size ?? 0;
const hasParticipantsInCall = participantCount > 0;
const callType =
call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined;
return {
id: room.roomId,
room,
name: room.name,
isBold: notifState.hasAnyNotificationOrActivity,
messagePreview,
notification: {
hasAnyNotificationOrActivity: notifState.hasAnyNotificationOrActivity || hasParticipantsInCall,
isUnsentMessage: notifState.isUnsentMessage,
invited: notifState.invited,
isMention: notifState.isMention,
isActivityNotification: notifState.isActivityNotification,
isNotification: notifState.isNotification,
hasUnreadCount: notifState.hasUnreadCount,
count: notifState.count,
muted: isNotificationMute,
callType: hasParticipantsInCall ? callType : undefined,
},
showMoreOptionsMenu,
showNotificationMenu,
isFavourite,
isLowPriority,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
roomNotifState,
};
}
public onOpenRoom = (): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
metricsTrigger: "RoomList",
});
};
public onMarkAsRead = async (): Promise<void> => {
await clearRoomNotification(this.props.room, this.props.client);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead");
};
public onMarkAsUnread = async (): Promise<void> => {
await setMarkedUnreadState(this.props.room, this.props.client, true);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread");
};
public onToggleFavorite = (): void => {
tagRoom(this.props.room, DefaultTagID.Favourite);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle");
};
public onToggleLowPriority = (): void => {
tagRoom(this.props.room, DefaultTagID.LowPriority);
};
public onInvite = (): void => {
dispatcher.dispatch({
action: "view_invite",
roomId: this.props.room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem");
};
public onCopyRoomLink = (): void => {
dispatcher.dispatch({
action: "copy_room",
room_id: this.props.room.roomId,
});
};
public onLeaveRoom = (): void => {
const isArchived = Boolean(this.props.room.tags[DefaultTagID.Archived]);
dispatcher.dispatch({
action: isArchived ? "forget_room" : "leave_room",
room_id: this.props.room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem");
};
public onSetRoomNotifState = (notifState: RoomNotifState): void => {
// Convert shared-components RoomNotifState to element-web RoomNotifState
let elementNotifState: ElementRoomNotifState;
switch (notifState) {
case "all_messages":
elementNotifState = ElementRoomNotifState.AllMessages;
break;
case "all_messages_loud":
elementNotifState = ElementRoomNotifState.AllMessagesLoud;
break;
case "mentions_only":
elementNotifState = ElementRoomNotifState.MentionsOnly;
break;
case "mute":
elementNotifState = ElementRoomNotifState.Mute;
break;
default:
elementNotifState = ElementRoomNotifState.AllMessages;
}
// Set the notification state using EchoChamber
const echoChamber = EchoChamber.forRoom(this.props.room);
echoChamber.notificationVolume = elementNotifState;
};
}
@@ -0,0 +1,450 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
BaseViewModel,
type RoomListSnapshot,
type FilterId,
type RoomListViewActions,
type RoomListViewState,
} from "@element-hq/web-shared-components";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { Action } from "../../dispatcher/actions";
import dispatcher from "../../dispatcher/dispatcher";
import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import SpaceStore from "../../stores/spaces/SpaceStore";
import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../stores/room-list-v3/RoomListStoreV3";
import { FilterKey } from "../../stores/room-list-v3/skip-list/filters";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { RoomListItemViewModel } from "./RoomListItemViewModel";
import { SdkContextClass } from "../../contexts/SDKContext";
import { hasCreateRoomRights } from "./utils";
interface RoomListViewViewModelProps {
client: MatrixClient;
}
const filterKeyToIdMap: Map<FilterKey, FilterId> = new Map([
[FilterKey.UnreadFilter, "unread"],
[FilterKey.PeopleFilter, "people"],
[FilterKey.RoomsFilter, "rooms"],
[FilterKey.FavouriteFilter, "favourite"],
[FilterKey.MentionsFilter, "mentions"],
[FilterKey.InvitesFilter, "invites"],
[FilterKey.LowPriorityFilter, "low_priority"],
]);
export class RoomListViewViewModel
extends BaseViewModel<RoomListSnapshot, RoomListViewViewModelProps>
implements RoomListViewActions
{
// State tracking
private activeFilter: FilterKey | undefined = undefined;
private roomsResult: RoomsResult;
private lastActiveRoomIndex: number | undefined = undefined;
// Child view model management
private roomItemViewModels = new Map<string, RoomListItemViewModel>();
private roomsMap = new Map<string, Room>();
public constructor(props: RoomListViewViewModelProps) {
const activeSpace = SpaceStore.instance.activeSpaceRoom;
// Get initial rooms
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined);
const canCreateRoom = hasCreateRoomRights(props.client, activeSpace);
const filterIds = [...filterKeyToIdMap.values()];
super(props, {
// Initial view state - start with empty, will populate in async init
isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms,
isRoomListEmpty: roomsResult.rooms.length === 0,
filterIds,
activeFilterId: undefined,
roomListState: {
activeRoomIndex: undefined,
spaceId: roomsResult.spaceId,
filterKeys: undefined,
},
roomIds: roomsResult.rooms.map((room) => room.roomId),
canCreateRoom,
});
this.roomsResult = roomsResult;
// Build initial roomsMap from roomsResult
this.updateRoomsMap(roomsResult);
// Subscribe to room list updates
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
// Subscribe to room list loaded
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsLoaded as any,
this.onListsLoaded,
);
// Subscribe to active room changes to update selected room
const dispatcherRef = dispatcher.register(this.onDispatch);
this.disposables.track(() => {
dispatcher.unregister(dispatcherRef);
});
// Track cleanup of all child view models
this.disposables.track(() => {
for (const viewModel of this.roomItemViewModels.values()) {
viewModel.dispose();
}
this.roomItemViewModels.clear();
});
}
public onToggleFilter = (filterId: FilterId): void => {
// Find the FilterKey by matching the filter ID
let filterKey: FilterKey | undefined = undefined;
for (const [key, id] of filterKeyToIdMap.entries()) {
if (id === filterId) {
filterKey = key;
break;
}
}
if (filterKey === undefined) return;
// Toggle the filter - if it's already active, deactivate it
const newFilter = this.activeFilter === filterKey ? undefined : filterKey;
this.activeFilter = newFilter;
// Update rooms result with new filter
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
// Update roomsMap immediately before clearing VMs
this.updateRoomsMap(this.roomsResult);
// Clear view models since room list changed
this.clearViewModels();
this.updateRoomListData();
};
/**
* Rebuild roomsMap when roomsResult changes.
* This maintains a quick lookup for room objects.
*/
private updateRoomsMap(roomsResult: RoomsResult): void {
this.roomsMap.clear();
for (const room of roomsResult.rooms) {
this.roomsMap.set(room.roomId, room);
}
}
/**
* Clear all child view models.
* Called when the room list structure changes (space change, filter change, etc.)
*/
private clearViewModels(): void {
for (const viewModel of this.roomItemViewModels.values()) {
viewModel.dispose();
}
this.roomItemViewModels.clear();
}
/**
* Get the ordered list of room IDs.
*/
public get roomIds(): string[] {
return this.roomsResult.rooms.map((room) => room.roomId);
}
/**
* Get a RoomListItemViewModel for a specific room.
* Creates a RoomListItemViewModel if needed, which manages per-room subscriptions.
* The view should call this only for visible rooms from the roomIds list.
* @throws Error if room is not found in roomsMap (indicates a programming error)
*/
public getRoomItemViewModel(roomId: string): RoomListItemViewModel {
// Check if we have a view model for this room
let viewModel = this.roomItemViewModels.get(roomId);
if (!viewModel) {
const room = this.roomsMap.get(roomId);
if (!room) {
throw new Error(`Room ${roomId} not found in roomsMap`);
}
// Create new view model
viewModel = new RoomListItemViewModel({
room,
client: this.props.client,
});
this.roomItemViewModels.set(roomId, viewModel);
}
// Return the view model - the view will call useViewModel() on it
return viewModel;
}
/**
* Update which rooms are currently visible.
* Called by the view when scroll position changes.
* Disposes of view models for rooms no longer visible.
*/
public updateVisibleRooms(startIndex: number, endIndex: number): void {
const allRoomIds = this.roomIds;
const newVisibleIds = allRoomIds.slice(startIndex, Math.min(endIndex, allRoomIds.length));
const newVisibleSet = new Set(newVisibleIds);
// Dispose view models for rooms no longer visible
for (const [roomId, viewModel] of this.roomItemViewModels.entries()) {
if (!newVisibleSet.has(roomId)) {
viewModel.dispose();
this.roomItemViewModels.delete(roomId);
}
}
}
private onDispatch = (payload: any): void => {
if (payload.action === Action.ActiveRoomChanged) {
// When the active room changes, update the room list data to reflect the new selected room
// Pass isRoomChange=true so sticky logic doesn't prevent the index from updating
this.updateRoomListData(true);
} else if (payload.action === Action.ViewRoomDelta) {
// Handle keyboard navigation shortcuts (Alt+ArrowUp/Down)
// This was previously handled by useRoomListNavigation hook
this.handleViewRoomDelta(payload as ViewRoomDeltaPayload);
}
};
/**
* Handle keyboard navigation shortcuts (Alt+ArrowUp/Down) to move between rooms.
* Supports both regular navigation and unread-only navigation.
* Migrated from useRoomListNavigation hook.
*/
private handleViewRoomDelta(payload: ViewRoomDeltaPayload): void {
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
if (!currentRoomId) return;
const { delta, unread } = payload;
const rooms = this.roomsResult.rooms;
const filteredRooms = unread
? // Filter the rooms to only include unread ones and the active room
rooms.filter((room) => {
const state = RoomNotificationStateStore.instance.getRoomState(room);
return room.roomId === currentRoomId || state.isUnread;
})
: rooms;
const currentIndex = filteredRooms.findIndex((room) => room.roomId === currentRoomId);
if (currentIndex === -1) return;
// Get the next/previous new room according to the delta
// Use slice to loop on the list
// If delta is -1 at the start of the list, it will go to the end
// If delta is 1 at the end of the list, it will go to the start
const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length);
if (!newRoom) return;
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: newRoom.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
});
}
/**
* Handle room list updates from RoomListStoreV3.
*
* This event fires when:
* - Room order changes (new messages, manual reordering)
* - Active space changes
* - Filters are applied
* - Rooms are added/removed
*
* Space changes are detected by comparing old vs new spaceId.
* This matches the old hook pattern where space changes were handled
* indirectly through room list updates.
*/
private onListsUpdate = (): void => {
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
const oldSpaceId = this.roomsResult.spaceId;
// Refresh room data from store
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
this.updateRoomsMap(this.roomsResult);
const newSpaceId = this.roomsResult.spaceId;
// Clear view models since room list structure changed
this.clearViewModels();
// Detect space change
if (oldSpaceId !== newSpaceId) {
// Space changed - get the last selected room for the new space to prevent flicker
const lastSelectedRoom = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpaceId);
this.updateRoomListData(true, lastSelectedRoom);
return;
}
// Normal room list update (not a space change)
this.updateRoomListData();
};
private onListsLoaded = (): void => {
// Room lists have finished loading
this.snapshot.merge({
isLoadingRooms: false,
});
};
/**
* Calculate the active room index based on the currently viewed room.
* Returns undefined if no room is selected or if the selected room is not in the current list.
*
* @param roomId - The room ID to find the index for (can be null/undefined)
*/
private getActiveRoomIndex(roomId: string | null | undefined): number | undefined {
if (!roomId) {
return undefined;
}
const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId);
return index >= 0 ? index : undefined;
}
/**
* Apply sticky room logic to keep the active room at the same index position.
* When the room list updates, this prevents the selected room from jumping around in the UI.
*
* @param isRoomChange - Whether this update is due to a room change (not a list update)
* @param roomId - The room ID to apply sticky logic for (can be null/undefined)
* @returns The modified rooms array with sticky positioning applied
*/
private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] {
const rooms = this.roomsResult.rooms;
if (!roomId) {
return rooms;
}
const newIndex = rooms.findIndex((room) => room.roomId === roomId);
const oldIndex = this.lastActiveRoomIndex;
// When opening another room, the index should obviously change
if (isRoomChange) {
return rooms;
}
// If oldIndex is undefined, then there was no active room before
// Similarly, if newIndex is -1, the active room is not in the current list
if (newIndex === -1 || oldIndex === undefined) {
return rooms;
}
// If the index hasn't changed, we have nothing to do
if (newIndex === oldIndex) {
return rooms;
}
// If the old index falls out of the bounds of the rooms array
// (usually because rooms were removed), we can no longer place
// the active room in the same old index
if (oldIndex > rooms.length - 1) {
return rooms;
}
// Making the active room sticky is as simple as removing it from
// its new index and placing it in the old index
const newRooms = [...rooms];
const [stickyRoom] = newRooms.splice(newIndex, 1);
newRooms.splice(oldIndex, 0, stickyRoom);
return newRooms;
}
private async updateRoomListData(
isRoomChange: boolean = false,
roomIdOverride: string | null = null,
): Promise<void> {
// Determine the room ID to use for calculations
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId();
// Apply sticky room logic to keep selected room at same position
const stickyRooms = this.applyStickyRoom(isRoomChange, roomId);
// Update roomsResult with sticky rooms
this.roomsResult = {
...this.roomsResult,
rooms: stickyRooms,
};
// Rebuild roomsMap with the reordered rooms
this.updateRoomsMap(this.roomsResult);
// Calculate the active room index after applying sticky logic
const activeRoomIndex = this.getActiveRoomIndex(roomId);
// Track the current active room index for future sticky calculations
this.lastActiveRoomIndex = activeRoomIndex;
// Build the complete state atomically to ensure consistency
// roomIds and roomListState must always be in sync
const roomIds = this.roomIds;
const roomListState: RoomListViewState = {
activeRoomIndex,
spaceId: this.roomsResult.spaceId,
filterKeys: this.roomsResult.filterKeys?.map((k) => String(k)),
};
const filterIds = [...filterKeyToIdMap.values()];
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
const isRoomListEmpty = roomIds.length === 0;
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
// Single atomic snapshot update
this.snapshot.merge({
isLoadingRooms,
isRoomListEmpty,
filterIds,
activeFilterId,
roomListState,
roomIds,
});
}
public createChatRoom = (): void => {
dispatcher.fire(Action.CreateChat);
};
public createRoom = (): void => {
const activeSpace = SpaceStore.instance.activeSpaceRoom;
if (activeSpace) {
dispatcher.dispatch({
action: Action.CreateRoom,
parent_space: activeSpace,
});
} else {
dispatcher.dispatch({
action: Action.CreateRoom,
});
}
};
}
@@ -7,12 +7,12 @@
import { type Room, KnownMembership, EventTimeline, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { isKnockDenied } from "../../../utils/membership";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { showCreateNewRoom } from "../../../utils/space";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { isKnockDenied } from "../../utils/membership";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
import { showCreateNewRoom } from "../../utils/space";
import dispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
/**
* Check if the user has access to the options menu.