Migrate element web code to use new RoomListPanel

This commit is contained in:
David Langley 2025-12-10 10:29:58 +00:00
parent 3023192ce7
commit c3ecfd2083
30 changed files with 1148 additions and 2579 deletions

View File

@ -0,0 +1,87 @@
/*
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 { BaseViewModel, type ComposeMenuSnapshot } from "@element-hq/web-shared-components";
import { RoomType, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import { hasCreateRoomRights, createRoom as createRoomFunc } from "./utils";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import { showCreateNewRoom } from "../../../utils/space";
interface ComposeMenuViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the ComposeMenu component.
* Manages room creation actions.
*/
export class ComposeMenuViewModel extends BaseViewModel<ComposeMenuSnapshot, ComposeMenuViewModelProps> {
private activeSpace: Room | null = null;
public constructor(props: ComposeMenuViewModelProps) {
super(props, ComposeMenuViewModel.createSnapshot(SpaceStore.instance.activeSpaceRoom, props.client));
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
}
private static createSnapshot(activeSpace: Room | null, client: MatrixClient): ComposeMenuSnapshot {
const canCreateRoom = hasCreateRoomRights(client, activeSpace);
const canCreateVideoRoom = SettingsStore.getValue("feature_video_rooms") && canCreateRoom;
return {
canCreateRoom,
canCreateVideoRoom,
createChatRoom: ComposeMenuViewModel.createChatRoom,
createRoom: () => ComposeMenuViewModel.createRoom(activeSpace),
createVideoRoom: () => ComposeMenuViewModel.createVideoRoom(activeSpace),
};
}
private onSpaceChanged = (): void => {
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
const canCreateRoom = hasCreateRoomRights(this.props.client, this.activeSpace);
const canCreateVideoRoom = SettingsStore.getValue("feature_video_rooms") && canCreateRoom;
this.snapshot.merge({
canCreateRoom,
canCreateVideoRoom,
createRoom: () => ComposeMenuViewModel.createRoom(this.activeSpace),
createVideoRoom: () => ComposeMenuViewModel.createVideoRoom(this.activeSpace),
});
};
private static createChatRoom = (): void => {
defaultDispatcher.fire(Action.CreateChat);
};
private static createRoom = (activeSpace: Room | null): void => {
createRoomFunc(activeSpace);
};
private static createVideoRoom = (activeSpace: Room | null): void => {
const elementCallVideoRoomsEnabled = SettingsStore.getValue("feature_element_call_video_rooms");
const type = elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo;
if (activeSpace) {
showCreateNewRoom(activeSpace, type);
} else {
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type,
});
}
};
}

View File

@ -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,
};
}

View File

@ -0,0 +1,149 @@
/*
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 { BaseViewModel, type RoomListHeaderSnapshot } from "@element-hq/web-shared-components";
import { RoomEvent, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { getMetaSpaceName, type MetaSpace, type SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import { hasCreateRoomRights } from "./utils";
import { SortOptionsMenuViewModel } from "./SortOptionsMenuViewModel";
import { SpaceMenuViewModel } from "./SpaceMenuViewModel";
import { ComposeMenuViewModel } from "./ComposeMenuViewModel";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
interface RoomListHeaderViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the RoomListHeader component.
* Manages header display and actions.
*/
export class RoomListHeaderViewModel extends BaseViewModel<
RoomListHeaderSnapshot,
RoomListHeaderViewModelProps
> {
private activeSpace: Room | null = null;
private sortOptionsMenuVm: SortOptionsMenuViewModel;
private spaceMenuVm: SpaceMenuViewModel;
private composeMenuVm: ComposeMenuViewModel;
public constructor(props: RoomListHeaderViewModelProps) {
const activeSpace = SpaceStore.instance.activeSpaceRoom;
// Create child ViewModels
const sortOptionsMenuVm = new SortOptionsMenuViewModel({ client: props.client });
const spaceMenuVm = new SpaceMenuViewModel({ client: props.client });
const composeMenuVm = new ComposeMenuViewModel({ client: props.client });
super(props, RoomListHeaderViewModel.createSnapshot(
SpaceStore.instance.activeSpace,
activeSpace,
SpaceStore.instance.allRoomsInHome,
props.client,
sortOptionsMenuVm,
spaceMenuVm,
composeMenuVm,
));
this.activeSpace = activeSpace;
this.sortOptionsMenuVm = sortOptionsMenuVm;
this.spaceMenuVm = spaceMenuVm;
this.composeMenuVm = composeMenuVm;
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
this.disposables.trackListener(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR as any, this.onHomeBehaviourChanged);
// Listen to room name changes if there's an active space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
}
private static createSnapshot(
spaceKey: SpaceKey,
activeSpace: Room | null,
allRoomsInHome: boolean,
client: MatrixClient,
sortOptionsMenuVm: SortOptionsMenuViewModel,
spaceMenuVm: SpaceMenuViewModel,
composeMenuVm: ComposeMenuViewModel,
): RoomListHeaderSnapshot {
const spaceName = activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
const isSpace = Boolean(activeSpace);
const canCreateRoom = hasCreateRoomRights(client, activeSpace);
const displayComposeMenu = canCreateRoom;
return {
title,
isSpace,
spaceMenuVm: isSpace ? spaceMenuVm : undefined,
displayComposeMenu,
composeMenuVm: displayComposeMenu ? composeMenuVm : undefined,
onComposeClick: !displayComposeMenu ? RoomListHeaderViewModel.createChatRoom : undefined,
sortOptionsMenuVm,
};
}
private onSpaceChanged = (): void => {
// Remove listener from old space
if (this.activeSpace) {
this.activeSpace.off(RoomEvent.Name, this.onRoomNameChanged);
}
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Add listener to new space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
const spaceKey = SpaceStore.instance.activeSpace;
const spaceName = this.activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, SpaceStore.instance.allRoomsInHome);
const isSpace = Boolean(this.activeSpace);
const canCreateRoom = hasCreateRoomRights(this.props.client, this.activeSpace);
const displayComposeMenu = canCreateRoom;
this.snapshot.merge({
title,
isSpace,
spaceMenuVm: isSpace ? this.spaceMenuVm : undefined,
displayComposeMenu,
composeMenuVm: displayComposeMenu ? this.composeMenuVm : undefined,
onComposeClick: !displayComposeMenu ? RoomListHeaderViewModel.createChatRoom : undefined,
});
};
private onHomeBehaviourChanged = (): void => {
const spaceKey = SpaceStore.instance.activeSpace;
const spaceName = this.activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, SpaceStore.instance.allRoomsInHome);
this.snapshot.merge({ title });
};
private onRoomNameChanged = (): void => {
if (this.activeSpace) {
this.snapshot.merge({ title: this.activeSpace.name });
}
};
private static createChatRoom = (): void => {
defaultDispatcher.fire(Action.CreateChat);
};
public override dispose(): void {
this.sortOptionsMenuVm.dispose();
this.spaceMenuVm.dispose();
this.composeMenuVm.dispose();
super.dispose();
}
}

View File

@ -1,224 +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 { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import PosthogTrackers from "../../../PosthogTrackers";
import { Action } from "../../../dispatcher/actions";
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import {
getMetaSpaceName,
type MetaSpace,
type SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_SELECTED_SPACE,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
shouldShowSpaceSettings,
showCreateNewRoom,
showSpaceInvite,
showSpacePreferences,
showSpaceSettings,
} from "../../../utils/space";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { createRoom, hasCreateRoomRights } from "./utils";
import { type SortOption, useSorter } from "./useSorter";
/**
* Hook to get the active space and its title.
*/
function useSpace(): { activeSpace: Room | null; title: string } {
const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom],
);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
const allRoomsInHome = useEventEmitterState(
SpaceStore.instance,
UPDATE_HOME_BEHAVIOUR,
() => SpaceStore.instance.allRoomsInHome,
);
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
return {
activeSpace,
title,
};
}
export interface RoomListHeaderViewState {
/**
* The title of the room list
*/
title: string;
/**
* Whether to display the compose menu
* True if the user can create rooms
*/
displayComposeMenu: boolean;
/**
* Whether to display the space menu
* True if there is an active space
*/
displaySpaceMenu: boolean;
/**
* Whether the user can create rooms
*/
canCreateRoom: boolean;
/**
* Whether the user can create video rooms
*/
canCreateVideoRoom: boolean;
/**
* Whether the user can invite in the active space
*/
canInviteInSpace: boolean;
/**
* Whether the user can access space settings
*/
canAccessSpaceSettings: boolean;
/**
* Create a chat room
* @param e - The click event
*/
createChatRoom: (e: Event) => void;
/**
* Create a room
* @param e - The click event
*/
createRoom: (e: Event) => void;
/**
* Create a video room
*/
createVideoRoom: () => void;
/**
* Open the active space home
*/
openSpaceHome: () => void;
/**
* Display the space invite dialog
*/
inviteInSpace: () => void;
/**
* Open the space preferences
*/
openSpacePreferences: () => void;
/**
* Open the space settings
*/
openSpaceSettings: () => void;
/**
* Change the sort order of the room-list.
*/
sort: (option: SortOption) => void;
/**
* The currently active sort option.
*/
activeSortOption: SortOption;
}
/**
* View model for the RoomListHeader.
*/
export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const matrixClient = useMatrixClientContext();
const { activeSpace, title } = useSpace();
const isSpaceRoom = Boolean(activeSpace);
const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace);
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms") && canCreateRoom;
const displayComposeMenu = canCreateRoom;
const displaySpaceMenu = isSpaceRoom;
const canInviteInSpace = Boolean(
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()),
);
const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace));
/* Actions */
const { activeSortOption, sort } = useSorter();
const createChatRoom = useCallback((e: Event) => {
defaultDispatcher.fire(Action.CreateChat);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
}, []);
const createRoomMemoized = useCallback(
(e: Event) => {
createRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
},
[activeSpace],
);
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const createVideoRoom = useCallback(() => {
const type = elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo;
if (activeSpace) {
showCreateNewRoom(activeSpace, type);
} else {
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type,
});
}
}, [activeSpace, elementCallVideoRoomsEnabled]);
const openSpaceHome = useCallback(() => {
// openSpaceHome is only available when there is an active space
if (!activeSpace) return;
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined,
});
}, [activeSpace]);
const inviteInSpace = useCallback(() => {
// inviteInSpace is only available when there is an active space
if (!activeSpace) return;
showSpaceInvite(activeSpace);
}, [activeSpace]);
const openSpacePreferences = useCallback(() => {
// openSpacePreferences is only available when there is an active space
if (!activeSpace) return;
showSpacePreferences(activeSpace);
}, [activeSpace]);
const openSpaceSettings = useCallback(() => {
// openSpaceSettings is only available when there is an active space
if (!activeSpace) return;
showSpaceSettings(activeSpace);
}, [activeSpace]);
return {
title,
displayComposeMenu,
displaySpaceMenu,
canCreateRoom,
canCreateVideoRoom,
canInviteInSpace,
canAccessSpaceSettings,
createChatRoom,
createRoom: createRoomMemoized,
createVideoRoom,
openSpaceHome,
inviteInSpace,
openSpacePreferences,
openSpaceSettings,
activeSortOption,
sort,
};
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -0,0 +1,61 @@
/*
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 { BaseViewModel, type RoomListPanelSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { RoomListSearchViewModel } from "./RoomListSearchViewModel";
import { RoomListHeaderViewModel } from "./RoomListHeaderViewModel";
import { RoomListViewViewModel } from "./RoomListViewViewModel";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
interface RoomListPanelViewModelProps {
client: MatrixClient;
}
/**
* Top-level ViewModel for the RoomListPanel component.
* Composes search, header, and view ViewModels.
*/
export class RoomListPanelViewModel extends BaseViewModel<RoomListPanelSnapshot, RoomListPanelViewModelProps> {
private searchVm: RoomListSearchViewModel | undefined;
private headerVm: RoomListHeaderViewModel;
private viewVm: RoomListViewViewModel;
public constructor(props: RoomListPanelViewModelProps) {
// Initialize child ViewModels
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
const searchVm = displayRoomSearch ? new RoomListSearchViewModel({ client: props.client }) : undefined;
const headerVm = new RoomListHeaderViewModel({ client: props.client });
const viewVm = new RoomListViewViewModel({ client: props.client });
super(props, {
ariaLabel: _t("room_list|list_title"),
searchVm,
headerVm,
viewVm,
});
this.searchVm = searchVm;
this.headerVm = headerVm;
this.viewVm = viewVm;
// Subscribe to child ViewModels to propagate updates
// Note: We don't need to update our snapshot when children update,
// because the child VM references stay the same and React will
// pick up changes from the child VMs directly via their own subscriptions
}
public override dispose(): void {
this.searchVm?.dispose();
this.headerVm.dispose();
this.viewVm.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,93 @@
/*
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 { BaseViewModel, type RoomListPrimaryFiltersSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../stores/room-list-v3/RoomListStoreV3";
interface RoomListPrimaryFiltersViewModelProps {
client: MatrixClient;
}
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")],
]);
/**
* ViewModel for the RoomListPrimaryFilters component.
* Manages the primary filter pills above the room list.
*/
export class RoomListPrimaryFiltersViewModel extends BaseViewModel<
RoomListPrimaryFiltersSnapshot,
RoomListPrimaryFiltersViewModelProps
> {
private activeFilter: FilterKey | undefined = undefined;
private toggleCallback: ((key: FilterKey) => void) | undefined = undefined;
public constructor(props: RoomListPrimaryFiltersViewModelProps) {
super(props, RoomListPrimaryFiltersViewModel.createInitialSnapshot());
// Listen to room list updates
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
}
private static createInitialSnapshot(): RoomListPrimaryFiltersSnapshot {
const filters = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
filters.push({
name: _t(name),
active: false,
toggle: () => {}, // Will be set by setToggleCallback
});
}
return { filters };
}
private createSnapshot(): RoomListPrimaryFiltersSnapshot {
const filters = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
filters.push({
name: _t(name),
active: this.activeFilter === key,
toggle: () => this.toggleCallback?.(key),
});
}
return { filters };
}
private onListsUpdate = (): void => {
// Regenerate filters with current active state
this.snapshot.set(this.createSnapshot());
};
public setToggleCallback(callback: (key: FilterKey) => void): void {
this.toggleCallback = callback;
this.snapshot.set(this.createSnapshot());
}
public setActiveFilter(filter: FilterKey | undefined): void {
this.activeFilter = filter;
this.snapshot.set(this.createSnapshot());
}
}

View File

@ -0,0 +1,86 @@
/*
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 { BaseViewModel, type RoomListSearchSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { MetaSpace } from "../../../stores/spaces";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
interface RoomListSearchViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the RoomListSearch component.
* Manages search, explore, and dial pad buttons.
*/
export class RoomListSearchViewModel extends BaseViewModel<
RoomListSearchSnapshot,
RoomListSearchViewModelProps
> {
public constructor(props: RoomListSearchViewModelProps) {
super(props, RoomListSearchViewModel.createSnapshot());
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
// Listen to protocol support changes
this.disposables.trackListener(LegacyCallHandler.instance, LegacyCallHandlerEvent.ProtocolSupport, this.onProtocolChanged);
}
private static createSnapshot(): RoomListSearchSnapshot {
const activeSpace = SpaceStore.instance.activeSpace;
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
const displayDialButton = LegacyCallHandler.instance.getSupportsPstnProtocol() ?? false;
return {
onSearchClick: RoomListSearchViewModel.onSearchClick,
showDialPad: displayDialButton,
onDialPadClick: displayDialButton ? RoomListSearchViewModel.onDialPadClick : undefined,
showExplore: displayExploreButton,
onExploreClick: displayExploreButton ? RoomListSearchViewModel.onExploreClick : undefined,
};
}
private onSpaceChanged = (): void => {
const activeSpace = SpaceStore.instance.activeSpace;
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
this.snapshot.merge({
showExplore: displayExploreButton,
onExploreClick: displayExploreButton ? RoomListSearchViewModel.onExploreClick : undefined,
});
};
private onProtocolChanged = (): void => {
const displayDialButton = LegacyCallHandler.instance.getSupportsPstnProtocol() ?? false;
this.snapshot.merge({
showDialPad: displayDialButton,
onDialPadClick: displayDialButton ? RoomListSearchViewModel.onDialPadClick : undefined,
});
};
private static onSearchClick = (): void => {
defaultDispatcher.fire(Action.OpenSpotlight);
};
private static onExploreClick = (): void => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
};
private static onDialPadClick = (): void => {
defaultDispatcher.fire(Action.OpenDialPad);
};
}

View File

@ -0,0 +1,337 @@
/*
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 { BaseViewModel, type RoomListViewModel as RoomListVMType, type RoomListItem, type RoomNotifState } from "@element-hq/web-shared-components";
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore, UPDATE_STATUS_INDICATOR } from "../../../stores/notifications/RoomNotificationStateStore";
import { DefaultTagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import { tagRoom } from "../../../utils/room/tagRoom";
import DMRoomMap from "../../../utils/DMRoomMap";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { RoomNotifState as ElementRoomNotifState } from "../../../RoomNotifs";
import { SdkContextClass } from "../../../contexts/SDKContext";
interface RoomListViewModelProps {
client: MatrixClient;
activeFilter?: FilterKey;
}
/**
* ViewModel for the RoomList component.
* Manages the room list data and actions.
*/
export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps>
implements Omit<RoomListVMType, 'getSnapshot' | 'subscribe'> {
private roomsResult: RoomsResult;
private activeFilter: FilterKey | undefined;
public constructor(props: RoomListWrapperViewModelProps) {
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(
props.activeFilter ? [props.activeFilter] : undefined
);
super(props, RoomListViewModel.createSnapshot(roomsResult, props.client));
this.roomsResult = roomsResult;
this.activeFilter = props.activeFilter;
// Listen to room list updates
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
// Listen to notification state changes
this.disposables.trackListener(
RoomNotificationStateStore.instance,
UPDATE_STATUS_INDICATOR as any,
this.onNotificationUpdate,
);
// Listen to message preview changes
this.disposables.trackListener(
MessagePreviewStore.instance,
UPDATE_EVENT,
this.onMessagePreviewUpdate,
);
// Listen to ViewRoomDelta action for keyboard navigation
this.disposables.trackDispatcher(dispatcher, this.onDispatch);
}
private static createSnapshot(
roomsResult: RoomsResult,
client: MatrixClient,
): any {
// Transform rooms into RoomListItems
const roomListItems: RoomListItem[] = roomsResult.rooms.map((room) => {
return RoomListViewModel.roomToListItem(room, client);
});
return {
roomsResult: {
spaceId: roomsResult.spaceId,
filterKeys: roomsResult.filterKeys,
rooms: roomListItems,
},
activeRoomIndex: undefined,
onKeyDown: undefined,
};
}
private static roomToListItem(room: Room, client: MatrixClient): RoomListItem {
const notifState = RoomNotificationStateStore.instance.getRoomState(room);
const messagePreview = MessagePreviewStore.instance.getPreviewForRoom(room, room.roomId);
// Get room tags for menu state
const roomTags = room.tags;
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]);
// 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 roomNotifState = echoChamber.notificationVolume;
// Determine which notification option is active
const isNotificationAllMessage = roomNotifState === ElementRoomNotifState.AllMessages;
const isNotificationAllMessageLoud = roomNotifState === ElementRoomNotifState.AllMessagesLoud;
const isNotificationMentionOnly = roomNotifState === ElementRoomNotifState.MentionsOnly;
const isNotificationMute = roomNotifState === ElementRoomNotifState.Mute;
return {
id: room.roomId,
name: room.name,
a11yLabel: room.name, // Simplified
isBold: notifState.hasAnyNotificationOrActivity,
messagePreview: messagePreview ? (messagePreview as any).text : undefined,
notification: {
hasAnyNotificationOrActivity: notifState.hasAnyNotificationOrActivity,
isUnsentMessage: notifState.isUnsentMessage,
invited: notifState.invited,
isMention: notifState.isMention,
isActivityNotification: notifState.isActivityNotification,
isNotification: notifState.isNotification,
count: notifState.count > 0 ? notifState.count : undefined,
muted: isNotificationMute,
},
showMoreOptionsMenu,
showNotificationMenu,
moreOptionsState: {
isFavourite,
isLowPriority,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
},
notificationState: {
isNotificationAllMessage,
isNotificationAllMessageLoud,
isNotificationMentionOnly,
isNotificationMute,
},
};
}
private onListsUpdate = (): void => {
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
// Transform rooms into RoomListItems
const roomListItems: RoomListItem[] = this.roomsResult.rooms.map((room) => {
return RoomListViewModel.roomToListItem(room, this.props.client);
});
this.snapshot.merge({
roomsResult: {
spaceId: this.roomsResult.spaceId,
filterKeys: this.roomsResult.filterKeys,
rooms: roomListItems,
},
});
};
public setActiveFilter(filter: FilterKey | undefined): void {
this.activeFilter = filter;
this.onListsUpdate();
}
private onNotificationUpdate = (): void => {
// Notification states changed, update room list items
this.onListsUpdate();
};
private onMessagePreviewUpdate = (): void => {
// Message previews changed, update room list items
this.onListsUpdate();
};
private onDispatch = (payload: any): void => {
if (payload.action !== Action.ViewRoomDelta) return;
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
if (!currentRoomId) return;
const { delta, unread } = payload as ViewRoomDeltaPayload;
// Get the rooms list to navigate through
const rooms = this.roomsResult.rooms;
// Filter rooms if unread navigation is requested
const filteredRooms = unread
? 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 room according to the delta
// Use modulo to wrap around the list
const newIndex = (currentIndex + delta + filteredRooms.length) % filteredRooms.length;
const newRoom = filteredRooms[newIndex];
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,
});
};
// Action implementations - using exact logic from RoomListItemMenuViewModel
public onOpenRoom = (roomId: string): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: "RoomList",
});
};
public onMarkAsRead = async (roomId: string): Promise<void> => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
await clearRoomNotification(room, this.props.client);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
};
public onMarkAsUnread = async (roomId: string): Promise<void> => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
await setMarkedUnreadState(room, this.props.client, true);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
};
public onToggleFavorite = (roomId: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
tagRoom(room, DefaultTagID.Favourite);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
};
public onToggleLowPriority = (roomId: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
tagRoom(room, DefaultTagID.LowPriority);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
};
public onInvite = (roomId: string): void => {
dispatcher.dispatch({
action: "view_invite",
roomId: roomId,
});
};
public onCopyRoomLink = (roomId: string): void => {
dispatcher.dispatch({
action: "copy_room",
room_id: roomId,
});
};
public onLeaveRoom = (roomId: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
const isArchived = Boolean(room.tags[DefaultTagID.Archived]);
dispatcher.dispatch({
action: isArchived ? "forget_room" : "leave_room",
room_id: roomId,
});
};
public onSetRoomNotifState = (roomId: string, notifState: RoomNotifState): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
// 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(room);
echoChamber.notificationVolume = elementNotifState;
// Trigger immediate update for optimistic UI
// Use setTimeout to allow the echo chamber to update first
setTimeout(() => this.onListsUpdate(), 0);
};
}

View File

@ -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,
};
}

View File

@ -0,0 +1,118 @@
/*
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 { BaseViewModel, type RoomListViewWrapperSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../stores/room-list-v3/RoomListStoreV3";
import { RoomListPrimaryFiltersViewModel } from "./RoomListPrimaryFiltersViewModel";
import { RoomListViewModel } from "./RoomListViewModel";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t } from "../../../languageHandler";
interface RoomListViewViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the RoomListView wrapper.
* Manages filters, loading state, empty state, and the room list.
*/
export class RoomListViewViewModel extends BaseViewModel<
RoomListViewWrapperSnapshot,
RoomListViewViewModelProps
> {
private filtersVm: RoomListPrimaryFiltersViewModel;
private roomListVm: RoomListViewModel;
private activeFilter: FilterKey | undefined = undefined;
public constructor(props: RoomListViewViewModelProps) {
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
const filtersVm = new RoomListPrimaryFiltersViewModel({ client: props.client });
const roomListVm = new RoomListViewModel({ client: props.client, activeFilter: undefined });
super(props, RoomListViewViewModel.createSnapshot(
isLoadingRooms,
filtersVm,
roomListVm,
));
this.filtersVm = filtersVm;
this.roomListVm = roomListVm;
// Set up filter toggle callback
this.filtersVm.setToggleCallback(this.onToggleFilter);
// Listen to room list loaded event
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsLoaded as any,
this.onListsLoaded,
);
// Listen to room list updates
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
}
private static createSnapshot(
isLoadingRooms: boolean,
filtersVm: RoomListPrimaryFiltersViewModel,
roomListVm: RoomListViewModel,
): RoomListViewWrapperSnapshot {
const roomsResult = roomListVm.getSnapshot().roomsResult;
const isRoomListEmpty = roomsResult.rooms.length === 0;
return {
isLoadingRooms,
isRoomListEmpty,
filtersVm,
roomListVm,
emptyStateTitle: "No rooms",
emptyStateDescription: "Start a chat or join a room to see it here",
emptyStateAction: undefined,
};
}
private onListsLoaded = (): void => {
this.snapshot.merge({ isLoadingRooms: false });
};
private onListsUpdate = (): void => {
// Child ViewModels will handle their own updates
// Just update empty state based on current room list
const roomsResult = this.roomListVm.getSnapshot().roomsResult;
const isRoomListEmpty = roomsResult.rooms.length === 0;
this.snapshot.merge({ isRoomListEmpty });
};
private onToggleFilter = (filterKey: FilterKey): void => {
// Toggle the filter - if it's already active, deactivate it
const newFilter = this.activeFilter === filterKey ? undefined : filterKey;
this.activeFilter = newFilter;
// Update the filters ViewModel to show which filter is active
this.filtersVm.setActiveFilter(newFilter);
// Update the room list ViewModel with the new filter
this.roomListVm.setActiveFilter(newFilter);
// Update empty state based on current room list
const roomsResult = this.roomListVm.getSnapshot().roomsResult;
const isRoomListEmpty = roomsResult.rooms.length === 0;
this.snapshot.merge({ isRoomListEmpty });
};
public override dispose(): void {
this.filtersVm.dispose();
this.roomListVm.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,62 @@
/*
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 { BaseViewModel, type SortOptionsMenuSnapshot, SortOption } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters";
import SettingsStore from "../../../settings/SettingsStore";
interface SortOptionsMenuViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the SortOptionsMenu component.
* Manages sort option selection.
*/
export class SortOptionsMenuViewModel extends BaseViewModel<
SortOptionsMenuSnapshot,
SortOptionsMenuViewModelProps
> {
public constructor(props: SortOptionsMenuViewModelProps) {
super(props, SortOptionsMenuViewModel.createSnapshot());
// Listen to room list updates that might include sort changes
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
}
private static createSnapshot(): SortOptionsMenuSnapshot {
const activeSortingAlgorithm = SettingsStore.getValue("RoomList.preferredSorting");
const activeSortOption =
activeSortingAlgorithm === SortingAlgorithm.Alphabetic ? SortOption.AToZ : SortOption.Activity;
return {
activeSortOption,
sort: SortOptionsMenuViewModel.sort,
};
}
private onListsUpdate = (): void => {
const activeSortingAlgorithm = SettingsStore.getValue("RoomList.preferredSorting");
const activeSortOption =
activeSortingAlgorithm === SortingAlgorithm.Alphabetic ? SortOption.AToZ : SortOption.Activity;
this.snapshot.merge({ activeSortOption });
};
private static sort = (option: SortOption): void => {
const sortingAlgorithm =
option === SortOption.AToZ ? SortingAlgorithm.Alphabetic : SortingAlgorithm.Recency;
RoomListStoreV3.instance.resort(sortingAlgorithm);
};
}

View File

@ -0,0 +1,120 @@
/*
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 { BaseViewModel, type SpaceMenuSnapshot } from "@element-hq/web-shared-components";
import { JoinRule, RoomEvent, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import { shouldShowSpaceSettings, showSpaceInvite, showSpacePreferences, showSpaceSettings } from "../../../utils/space";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
interface SpaceMenuViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the SpaceMenu component.
* Manages space-specific actions.
*/
export class SpaceMenuViewModel extends BaseViewModel<SpaceMenuSnapshot, SpaceMenuViewModelProps> {
private activeSpace: Room | null = null;
public constructor(props: SpaceMenuViewModelProps) {
super(props, SpaceMenuViewModel.createSnapshot(SpaceStore.instance.activeSpaceRoom, props.client));
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
// Listen to room name changes if there's an active space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
}
private static createSnapshot(activeSpace: Room | null, client: MatrixClient): SpaceMenuSnapshot {
const title = activeSpace?.name ?? "";
const canInviteInSpace = Boolean(
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(client.getSafeUserId()),
);
const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace));
return {
title,
canInviteInSpace,
canAccessSpaceSettings,
openSpaceHome: () => SpaceMenuViewModel.openSpaceHome(activeSpace),
inviteInSpace: () => SpaceMenuViewModel.inviteInSpace(activeSpace),
openSpacePreferences: () => SpaceMenuViewModel.openSpacePreferences(activeSpace),
openSpaceSettings: () => SpaceMenuViewModel.openSpaceSettings(activeSpace),
};
}
private onSpaceChanged = (): void => {
// Remove listener from old space
if (this.activeSpace) {
this.activeSpace.off(RoomEvent.Name, this.onRoomNameChanged);
}
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Add listener to new space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
const title = this.activeSpace?.name ?? "";
const canInviteInSpace = Boolean(
this.activeSpace?.getJoinRule() === JoinRule.Public || this.activeSpace?.canInvite(this.props.client.getSafeUserId()),
);
const canAccessSpaceSettings = Boolean(this.activeSpace && shouldShowSpaceSettings(this.activeSpace));
this.snapshot.merge({
title,
canInviteInSpace,
canAccessSpaceSettings,
openSpaceHome: () => SpaceMenuViewModel.openSpaceHome(this.activeSpace),
inviteInSpace: () => SpaceMenuViewModel.inviteInSpace(this.activeSpace),
openSpacePreferences: () => SpaceMenuViewModel.openSpacePreferences(this.activeSpace),
openSpaceSettings: () => SpaceMenuViewModel.openSpaceSettings(this.activeSpace),
});
};
private onRoomNameChanged = (): void => {
if (this.activeSpace) {
this.snapshot.merge({ title: this.activeSpace.name });
}
};
private static openSpaceHome = (activeSpace: Room | null): void => {
if (!activeSpace) return;
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined,
});
};
private static inviteInSpace = (activeSpace: Room | null): void => {
if (!activeSpace) return;
showSpaceInvite(activeSpace);
};
private static openSpacePreferences = (activeSpace: Room | null): void => {
if (!activeSpace) return;
showSpacePreferences(activeSpace);
};
private static openSpaceSettings = (activeSpace: Room | null): void => {
if (!activeSpace) return;
showSpaceSettings(activeSpace);
};
}

View File

@ -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, type TranslationKey } 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,
};
}

View File

@ -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 };
}

View File

@ -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,
});
});
}

View File

@ -1,62 +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 { useState } from "react";
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters";
import SettingsStore from "../../../settings/SettingsStore";
/**
* Sorting options made available to the view.
*/
export const enum SortOption {
Activity = SortingAlgorithm.Recency,
AToZ = SortingAlgorithm.Alphabetic,
}
/**
* {@link SortOption} holds almost the same information as
* {@link SortingAlgorithm}. This is done intentionally to
* prevent the view from having a dependence on the
* model (which is the store in this case).
*/
const sortingAlgorithmToSortingOption = {
[SortingAlgorithm.Alphabetic]: SortOption.AToZ,
[SortingAlgorithm.Recency]: SortOption.Activity,
};
const sortOptionToSortingAlgorithm = {
[SortOption.AToZ]: SortingAlgorithm.Alphabetic,
[SortOption.Activity]: SortingAlgorithm.Recency,
};
interface SortState {
sort: (option: SortOption) => void;
activeSortOption: SortOption;
}
/**
* This hook does two things:
* - Provides a way to track the currently active sort option.
* - Provides a function to resort the room list.
*/
export function useSorter(): SortState {
const [activeSortingAlgorithm, setActiveSortingAlgorithm] = useState(() =>
SettingsStore.getValue("RoomList.preferredSorting"),
);
const sort = (option: SortOption): void => {
const sortingAlgorithm = sortOptionToSortingAlgorithm[option];
RoomListStoreV3.instance.resort(sortingAlgorithm);
setActiveSortingAlgorithm(sortingAlgorithm);
};
return {
sort,
activeSortOption: sortingAlgorithmToSortingOption[activeSortingAlgorithm!],
};
}

View File

@ -1,139 +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 type { Optional } from "matrix-events-sdk";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
function getIndexByRoomId(rooms: Room[], roomId: Optional<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;
}

View File

@ -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>
);
}

View File

@ -1,110 +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, useMemo, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { RoomList as SharedRoomList, type RoomsResult, type FilterKey } from "@element-hq/web-shared-components";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView";
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;
}
/**
* Room adapter that wraps Matrix Room objects with an id property for the shared component
*/
interface RoomAdapter {
id: string;
room: Room;
}
/**
* A virtualized list of rooms.
* This component adapts element-web's room list to use the shared RoomList component.
*/
export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
const roomCount = roomsResult.rooms.length;
/**
* Adapt the element-web roomsResult to the shared component's format
*/
const adaptedRoomsResult: RoomsResult<RoomAdapter> = useMemo(
() => ({
spaceId: roomsResult.spaceId,
filterKeys: roomsResult.filterKeys as FilterKey[] | undefined,
rooms: roomsResult.rooms.map((room) => ({
id: room.roomId,
room,
})),
}),
[roomsResult],
);
/**
* Render a room item using the RoomListItemView
*/
const renderItem = useCallback(
(
index: number,
item: RoomAdapter,
isSelected: boolean,
isFocused: boolean,
tabIndex: number,
roomCount: number,
onFocus: (item: RoomAdapter, e: React.FocusEvent) => void,
): React.ReactNode => {
return (
<RoomListItemView
room={item.room}
key={item.id}
isSelected={isSelected}
isFocused={isFocused}
tabIndex={tabIndex}
roomIndex={index}
roomCount={roomCount}
onFocus={(room, e) => onFocus(item, e)}
/>
);
},
[],
);
/**
* Handle keyboard events for landmark navigation
*/
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;
}
}, []);
return (
<SharedRoomList
roomsResult={adaptedRoomsResult}
activeIndex={activeIndex}
renderItem={renderItem}
onKeyDown={keyDownCallback}
ariaLabel={_t("room_list|list_title")}
/>
);
}

View File

@ -1,185 +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 } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home";
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import { Flex } from "@element-hq/web-shared-components";
import { _t } from "../../../../languageHandler";
import {
type RoomListHeaderViewState,
useRoomListHeaderViewModel,
} from "../../../viewmodels/roomlist/RoomListHeaderViewModel";
import { RoomListOptionsMenu } from "./RoomListOptionsMenu";
import { ReleaseAnnouncement } from "../../../structures/ReleaseAnnouncement";
/**
* The header view for the room list
* The space name is displayed and a compose menu is shown if the user can create rooms
*/
export function RoomListHeaderView(): JSX.Element {
const vm = useRoomListHeaderViewModel();
return (
<Flex
as="header"
className="mx_RoomListHeaderView"
aria-label={_t("room|context_menu|title")}
justify="space-between"
align="center"
data-testid="room-list-header"
>
<Flex className="mx_RoomListHeaderView_title" align="center" gap="var(--cpd-space-1x)">
<h1 title={vm.title}>{vm.title}</h1>
{vm.displaySpaceMenu && <SpaceMenu vm={vm} />}
</Flex>
<Flex align="center" gap="var(--cpd-space-2x)">
<ReleaseAnnouncement
feature="newRoomList_sort"
header={_t("room_list|release_announcement|sort|title")}
description={_t("room_list|release_announcement|sort|description")}
closeLabel={_t("room_list|release_announcement|next")}
placement="bottom"
>
<div className="mx_RoomListHeaderView_ReleaseAnnouncementAnchor">
<RoomListOptionsMenu vm={vm} />
</div>
</ReleaseAnnouncement>
{/* If we don't display the compose menu, it means that the user can only send DM */}
<ReleaseAnnouncement
feature="newRoomList_intro"
header={_t("room_list|release_announcement|intro|title")}
description={_t("room_list|release_announcement|intro|description")}
closeLabel={_t("room_list|release_announcement|next")}
>
<div className="mx_RoomListHeaderView_ReleaseAnnouncementAnchor">
{vm.displayComposeMenu ? (
<ComposeMenu vm={vm} />
) : (
<IconButton
onClick={(e) => vm.createChatRoom(e.nativeEvent)}
tooltip={_t("action|new_conversation")}
>
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
)}
</div>
</ReleaseAnnouncement>
</Flex>
</Flex>
);
}
interface SpaceMenuProps {
/**
* The view model for the room list header
*/
vm: RoomListHeaderViewState;
}
/**
* The space menu for the room list header
*/
function SpaceMenu({ vm }: SpaceMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={vm.title}
side="right"
align="start"
trigger={
<IconButton className="mx_SpaceMenu_button" aria-label={_t("room_list|open_space_menu")} size="20px">
<ChevronDownIcon color="var(--cpd-color-icon-secondary)" />
</IconButton>
}
>
<MenuItem
Icon={HomeIcon}
label={_t("room_list|space_menu|home")}
onSelect={vm.openSpaceHome}
hideChevron={true}
/>
{vm.canInviteInSpace && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={vm.inviteInSpace}
hideChevron={true}
/>
)}
<MenuItem
Icon={PreferencesIcon}
label={_t("common|preferences")}
onSelect={vm.openSpacePreferences}
hideChevron={true}
/>
{vm.canAccessSpaceSettings && (
<MenuItem
Icon={SettingsIcon}
label={_t("room_list|space_menu|space_settings")}
onSelect={vm.openSpaceSettings}
hideChevron={true}
/>
)}
</Menu>
);
}
interface ComposeMenuProps {
/**
* The view model for the room list header
*/
vm: RoomListHeaderViewState;
}
/**
* The compose menu for the room list header
*/
function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
showTitle={false}
title={_t("action|open_menu")}
side="right"
align="start"
trigger={
<IconButton tooltip={_t("action|new_conversation")}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
}
>
<MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={vm.createChatRoom} hideChevron={true} />
{vm.canCreateRoom && (
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} hideChevron={true} />
)}
{vm.canCreateVideoRoom && (
<MenuItem
Icon={VideoCallIcon}
label={_t("action|new_video_room")}
onSelect={vm.createVideoRoom}
hideChevron={true}
/>
)}
</Menu>
);
}

View File

@ -1,50 +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;
/**
* Set the menu open state.
*/
setMenuOpen: (isOpen: boolean) => void;
}
/**
* A view for the room list item context menu.
*/
export function RoomListItemContextMenuView({
room,
setMenuOpen,
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}
onOpenChange={setMenuOpen}
>
<MoreOptionContent vm={vm} />
</ContextMenu>
);
}

View File

@ -1,281 +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 ComponentProps, type JSX, type Ref, useState } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import { type Room } from "matrix-js-sdk/src/matrix";
import { Flex } from "@element-hq/web-shared-components";
import { _t } from "../../../../languageHandler";
import {
type RoomListItemMenuViewState,
useRoomListItemMenuViewModel,
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
import { RoomNotifState } from "../../../../RoomNotifs";
interface RoomListItemMenuViewProps {
/**
* The room to display the menu for.
*/
room: Room;
/**
* Set the menu open state.
*/
setMenuOpen: (isOpen: boolean) => void;
}
/**
* A view for the room list item menu.
*/
export function RoomListItemMenuView({ room, setMenuOpen }: RoomListItemMenuViewProps): JSX.Element {
const vm = useRoomListItemMenuViewModel(room);
return (
<Flex className="mx_RoomListItemMenuView" align="center" gap="var(--cpd-space-1x)">
{vm.showMoreOptionsMenu && <MoreOptionsMenu setMenuOpen={setMenuOpen} vm={vm} />}
{vm.showNotificationMenu && <NotificationMenu setMenuOpen={setMenuOpen} vm={vm} />}
</Flex>
);
}
interface MoreOptionsMenuProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
/**
* Set the menu open state.
* @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
/**
* The more options menu for the room list item.
*/
function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={<MoreOptionsButton size="24px" />}
>
<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 MoreOptionsButtonProps extends ComponentProps<typeof IconButton> {
ref?: Ref<HTMLButtonElement>;
}
/**
* A button to trigger the more options menu.
*/
const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element {
return (
<Tooltip label={_t("room_list|room|more_options")}>
<IconButton aria-label={_t("room_list|room|more_options")} {...props}>
<OverflowIcon />
</IconButton>
</Tooltip>
);
};
interface NotificationMenuProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
/**
* Set the menu open state.
* @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
function NotificationMenu({ vm, setMenuOpen }: NotificationMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
>
<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>
</Menu>
</div>
);
}
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
/**
* Whether the room is muted.
*/
isRoomMuted: boolean;
ref?: Ref<HTMLButtonElement>;
}
/**
* A button to trigger the notification menu.
*/
const NotificationButton = function MoreOptionsButton({
isRoomMuted,
ref,
...props
}: NotificationButtonProps): JSX.Element {
return (
<Tooltip label={_t("room_list|notification_options")}>
<IconButton aria-label={_t("room_list|notification_options")} {...props} ref={ref}>
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
</IconButton>
</Tooltip>
);
};

View File

@ -1,108 +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, useCallback, type ReactNode } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { RoomListItem as SharedRoomListItem } 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.
* This component wraps the shared RoomListItem and provides element-web specific
* implementations for the avatar, notifications, and menus.
*/
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
isFocused,
onFocus,
roomIndex,
roomCount,
...props
}: RoomListItemViewProps): JSX.Element {
const vm = useRoomListItemViewModel(room);
// Wrap onFocus to include the room parameter
const handleFocus = useCallback(
(e: React.FocusEvent) => {
onFocus(room, e);
},
[onFocus, room],
);
// Create the avatar component
const avatar = <RoomAvatarView room={room} />;
// Create the notification decoration component
const notificationDecoration = vm.showNotificationDecoration ? (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
callType={vm.callType}
/>
) : null;
// Create the hover menu component
const hoverMenu = vm.showHoverMenu ? <RoomListItemMenuView room={room} setMenuOpen={() => {}} /> : null;
// Create the context menu wrapper function
const contextMenuWrapper = vm.showContextMenu
? (content: ReactNode) => (
<RoomListItemContextMenuView room={room} setMenuOpen={() => {}}>
{content}
</RoomListItemContextMenuView>
)
: undefined;
return (
<SharedRoomListItem
viewModel={vm}
isSelected={isSelected}
isFocused={isFocused}
onFocus={handleFocus}
roomIndex={roomIndex}
roomCount={roomCount}
avatar={avatar}
notificationDecoration={notificationDecoration}
hoverMenu={hoverMenu}
contextMenuWrapper={contextMenuWrapper}
{...props}
/>
);
});

View File

@ -1,68 +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 { IconButton, Menu, MenuTitle, Tooltip, RadioMenuItem } from "@vector-im/compound-web";
import React, { type Ref, type JSX, useState, useCallback } from "react";
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { _t } from "../../../../languageHandler";
import { SortOption } from "../../../viewmodels/roomlist/useSorter";
import { type RoomListHeaderViewState } from "../../../viewmodels/roomlist/RoomListHeaderViewModel";
interface MenuTriggerProps extends React.ComponentProps<typeof IconButton> {
ref?: Ref<HTMLButtonElement>;
}
const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => (
<Tooltip label={_t("room_list|room_options")}>
<IconButton aria-label={_t("room_list|room_options")} {...props} ref={ref}>
<OverflowHorizontalIcon color="var(--cpd-color-icon-secondary)" />
</IconButton>
</Tooltip>
);
interface Props {
/**
* The view model for the room list view
*/
vm: RoomListHeaderViewState;
}
export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
const [open, setOpen] = useState(false);
const onActivitySelected = useCallback(() => {
vm.sort(SortOption.Activity);
}, [vm]);
const onAtoZSelected = useCallback(() => {
vm.sort(SortOption.AToZ);
}, [vm]);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|room_options")}
showTitle={false}
align="start"
trigger={<MenuTrigger />}
>
<MenuTitle title={_t("room_list|sort")} />
<RadioMenuItem
label={_t("room_list|sort_type|activity")}
checked={vm.activeSortOption === SortOption.Activity}
onSelect={onActivitySelected}
/>
<RadioMenuItem
label={_t("room_list|sort_type|atoz")}
checked={vm.activeSortOption === SortOption.AToZ}
onSelect={onAtoZSelected}
/>
</Menu>
);
}

View File

@ -5,24 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { RoomListPanel as SharedRoomListPanel } from "@element-hq/web-shared-components";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
import { RoomListSearch } from "./RoomListSearch";
import { RoomListHeaderView } from "./RoomListHeaderView";
import { RoomListView } from "./RoomListView";
import { _t } from "../../../../languageHandler";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
import { type IState as IRovingTabIndexState } from "../../../../accessibility/RovingTabIndex";
import { RoomListPanelViewModel } from "../../../viewmodels/roomlist/RoomListPanelViewModel";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import RoomAvatar from "../../avatars/RoomAvatar";
import type { RoomListItem } from "@element-hq/web-shared-components";
type RoomListPanelProps = {
/**
* Current active space
* See {@link RoomListSearch}
* This is kept for backward compatibility but not currently used by the ViewModel
*/
activeSpace: string;
};
@ -31,9 +29,24 @@ type RoomListPanelProps = {
* The panel of the room list
*/
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) => {
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
const client = useMatrixClientContext();
const [focusedElement, setFocusedElement] = useState<Element | null>(null);
// Create ViewModel instance - use ref to survive strict mode double-mounting
const vmRef = useRef<RoomListPanelViewModel | null>(null);
if (!vmRef.current) {
vmRef.current = new RoomListPanelViewModel({ client });
}
const vm = vmRef.current;
// Clean up ViewModel on unmount
useEffect(() => {
return () => {
vm.dispose();
vmRef.current = null;
};
}, [vm]);
const onFocus = useCallback((ev: React.FocusEvent): void => {
setFocusedElement(ev.target as Element);
}, []);
@ -58,12 +71,21 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
[focusedElement],
);
// Render avatar for room items
const renderAvatar = useCallback(
(roomItem: RoomListItem) => {
// Get the actual room from the client
const room = client.getRoom(roomItem.id);
if (!room) return null;
return <RoomAvatar room={room} size="32px" />;
},
[client],
);
return (
<SharedRoomListPanel
ariaLabel={_t("room_list|list_title")}
searchSlot={displayRoomSearch ? <RoomListSearch activeSpace={activeSpace} /> : undefined}
headerSlot={<RoomListHeaderView />}
contentSlot={<RoomListView />}
vm={vm}
renderAvatar={renderAvatar}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}

View File

@ -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;
}

View File

@ -1,89 +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 } from "react";
import { Button } from "@vector-im/compound-web";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search";
import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad";
import { Flex } from "@element-hq/web-shared-components";
import { IS_MAC, Key } from "../../../../Keyboard";
import { _t } from "../../../../languageHandler";
import { ALTERNATE_KEY_NAME } from "../../../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
import { MetaSpace } from "../../../../stores/spaces";
import { Action } from "../../../../dispatcher/actions";
import PosthogTrackers from "../../../../PosthogTrackers";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../../LegacyCallHandler";
type RoomListSearchProps = {
/**
* Current active space
* The explore button is only displayed in the Home meta space
*/
activeSpace: string;
};
/**
* A search component to be displayed at the top of the room list
* The `Explore` button is displayed only in the Home meta space and when UIComponent.ExploreRooms is enabled.
*/
export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Element {
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
// We only display the dial button if the user is can make PSTN calls
const displayDialButton = useTypedEventEmitterState(
LegacyCallHandler.instance,
LegacyCallHandlerEvent.ProtocolSupport,
() => LegacyCallHandler.instance.getSupportsPstnProtocol(),
);
return (
<Flex className="mx_RoomListSearch" role="search" gap="var(--cpd-space-2x)" align="center">
<Button
className="mx_RoomListSearch_search"
kind="secondary"
size="sm"
Icon={SearchIcon}
onClick={() => defaultDispatcher.fire(Action.OpenSpotlight)}
>
<Flex as="span" justify="space-between">
<span className="mx_RoomListSearch_search_text">{_t("action|search")}</span>
<kbd>{IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"}</kbd>
</Flex>
</Button>
{displayDialButton && (
<Button
kind="secondary"
size="sm"
Icon={DialPadIcon}
iconOnly={true}
aria-label={_t("left_panel|open_dial_pad")}
onClick={(ev) => {
defaultDispatcher.fire(Action.OpenDialPad);
}}
/>
)}
{displayExploreButton && (
<Button
kind="secondary"
size="sm"
Icon={ExploreIcon}
iconOnly={true}
aria-label={_t("action|explore_rooms")}
onClick={(ev) => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
}}
/>
)}
</Flex>
);
}

View File

@ -1,47 +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 } from "react";
import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "./RoomList";
import { EmptyRoomList } from "./EmptyRoomList";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import { _t } from "../../../../languageHandler";
import { ReleaseAnnouncement } from "../../../structures/ReleaseAnnouncement";
/**
* Host the room list and the (future) room filters
*/
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 (
<>
<ReleaseAnnouncement
feature="newRoomList_filter"
header={_t("room_list|release_announcement|filter|title")}
description={_t("room_list|release_announcement|filter|description")}
closeLabel={_t("room_list|release_announcement|next")}
placement="right"
>
<div>
<RoomListPrimaryFilters vm={vm} />
</div>
</ReleaseAnnouncement>
{listBody}
</>
);
}