mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 20:26:19 +02:00
Migrate element web code to use new RoomListPanel
This commit is contained in:
parent
3023192ce7
commit
c3ecfd2083
87
src/components/viewmodels/roomlist/ComposeMenuViewModel.ts
Normal file
87
src/components/viewmodels/roomlist/ComposeMenuViewModel.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
149
src/components/viewmodels/roomlist/RoomListHeaderViewModel.ts
Normal file
149
src/components/viewmodels/roomlist/RoomListHeaderViewModel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -1,226 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
|
||||
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { tagRoom } from "../../../utils/room/tagRoom";
|
||||
import { RoomNotifState } from "../../../RoomNotifs";
|
||||
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
|
||||
|
||||
export interface RoomListItemMenuViewState {
|
||||
/**
|
||||
* Whether the more options menu should be shown.
|
||||
*/
|
||||
showMoreOptionsMenu: boolean;
|
||||
/**
|
||||
* Whether the notification menu should be shown.
|
||||
*/
|
||||
showNotificationMenu: boolean;
|
||||
/**
|
||||
* Whether the room is a favourite room.
|
||||
*/
|
||||
isFavourite: boolean;
|
||||
/**
|
||||
* Whether the room is a low priority room.
|
||||
*/
|
||||
isLowPriority: boolean;
|
||||
/**
|
||||
* Can invite other user's in the room.
|
||||
*/
|
||||
canInvite: boolean;
|
||||
/**
|
||||
* Can copy the room link.
|
||||
*/
|
||||
canCopyRoomLink: boolean;
|
||||
/**
|
||||
* Can mark the room as read.
|
||||
*/
|
||||
canMarkAsRead: boolean;
|
||||
/**
|
||||
* Can mark the room as unread.
|
||||
*/
|
||||
canMarkAsUnread: boolean;
|
||||
/**
|
||||
* Whether the notification is set to all messages.
|
||||
*/
|
||||
isNotificationAllMessage: boolean;
|
||||
/**
|
||||
* Whether the notification is set to all messages loud.
|
||||
*/
|
||||
isNotificationAllMessageLoud: boolean;
|
||||
/**
|
||||
* Whether the notification is set to mentions and keywords only.
|
||||
*/
|
||||
isNotificationMentionOnly: boolean;
|
||||
/**
|
||||
* Whether the notification is muted.
|
||||
*/
|
||||
isNotificationMute: boolean;
|
||||
/**
|
||||
* Mark the room as read.
|
||||
* @param evt
|
||||
*/
|
||||
markAsRead: (evt: Event) => void;
|
||||
/**
|
||||
* Mark the room as unread.
|
||||
* @param evt
|
||||
*/
|
||||
markAsUnread: (evt: Event) => void;
|
||||
/**
|
||||
* Toggle the room as favourite.
|
||||
* @param evt
|
||||
*/
|
||||
toggleFavorite: (evt: Event) => void;
|
||||
/**
|
||||
* Toggle the room as low priority.
|
||||
*/
|
||||
toggleLowPriority: () => void;
|
||||
/**
|
||||
* Invite other users in the room.
|
||||
* @param evt
|
||||
*/
|
||||
invite: (evt: Event) => void;
|
||||
/**
|
||||
* Copy the room link in the clipboard.
|
||||
* @param evt
|
||||
*/
|
||||
copyRoomLink: (evt: Event) => void;
|
||||
/**
|
||||
* Leave the room.
|
||||
* @param evt
|
||||
*/
|
||||
leaveRoom: (evt: Event) => void;
|
||||
/**
|
||||
* Set the room notification state.
|
||||
* @param state
|
||||
*/
|
||||
setRoomNotifState: (state: RoomNotifState) => void;
|
||||
}
|
||||
|
||||
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
|
||||
const { level: notificationLevel } = useUnreadNotifications(room);
|
||||
|
||||
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
|
||||
const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]);
|
||||
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
|
||||
|
||||
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
|
||||
const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
|
||||
|
||||
const canMarkAsRead = notificationLevel > NotificationLevel.None;
|
||||
const canMarkAsUnread = !canMarkAsRead && !isArchived;
|
||||
|
||||
const canInvite =
|
||||
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
|
||||
const canCopyRoomLink = !isDm;
|
||||
|
||||
const [roomNotifState, setRoomNotifState] = useNotificationState(room);
|
||||
const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages;
|
||||
const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud;
|
||||
const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly;
|
||||
const isNotificationMute = roomNotifState === RoomNotifState.Mute;
|
||||
|
||||
// Actions
|
||||
|
||||
const markAsRead = useCallback(
|
||||
async (evt: Event): Promise<void> => {
|
||||
await clearRoomNotification(room, matrixClient);
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt);
|
||||
},
|
||||
[room, matrixClient],
|
||||
);
|
||||
|
||||
const markAsUnread = useCallback(
|
||||
async (evt: Event): Promise<void> => {
|
||||
await setMarkedUnreadState(room, matrixClient, true);
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", evt);
|
||||
},
|
||||
[room, matrixClient],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
(evt: Event): void => {
|
||||
tagRoom(room, DefaultTagID.Favourite);
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
|
||||
},
|
||||
[room],
|
||||
);
|
||||
|
||||
const toggleLowPriority = useCallback((): void => tagRoom(room, DefaultTagID.LowPriority), [room]);
|
||||
|
||||
const invite = useCallback(
|
||||
(evt: Event): void => {
|
||||
dispatcher.dispatch({
|
||||
action: "view_invite",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", evt);
|
||||
},
|
||||
[room],
|
||||
);
|
||||
|
||||
const copyRoomLink = useCallback(
|
||||
(evt: Event): void => {
|
||||
dispatcher.dispatch({
|
||||
action: "copy_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
|
||||
},
|
||||
[room],
|
||||
);
|
||||
|
||||
const leaveRoom = useCallback(
|
||||
(evt: Event): void => {
|
||||
dispatcher.dispatch({
|
||||
action: isArchived ? "forget_room" : "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", evt);
|
||||
},
|
||||
[room, isArchived],
|
||||
);
|
||||
|
||||
return {
|
||||
showMoreOptionsMenu,
|
||||
showNotificationMenu,
|
||||
isFavourite,
|
||||
isLowPriority,
|
||||
canInvite,
|
||||
canCopyRoomLink,
|
||||
canMarkAsRead,
|
||||
canMarkAsUnread,
|
||||
isNotificationAllMessage,
|
||||
isNotificationAllMessageLoud,
|
||||
isNotificationMentionOnly,
|
||||
isNotificationMute,
|
||||
markAsRead,
|
||||
markAsUnread,
|
||||
toggleFavorite,
|
||||
toggleLowPriority,
|
||||
invite,
|
||||
copyRoomLink,
|
||||
leaveRoom,
|
||||
setRoomNotifState,
|
||||
};
|
||||
}
|
||||
@ -1,250 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
|
||||
import { CallEvent, type ConnectionState } from "../../../models/Call";
|
||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
|
||||
|
||||
export interface RoomListItemViewState {
|
||||
/**
|
||||
* The name of the room.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Whether the context menu should be shown.
|
||||
*/
|
||||
showContextMenu: boolean;
|
||||
/**
|
||||
* Whether the hover menu should be shown.
|
||||
*/
|
||||
showHoverMenu: boolean;
|
||||
/**
|
||||
* Open the room having given roomId.
|
||||
*/
|
||||
openRoom: () => void;
|
||||
/**
|
||||
* The a11y label for the room list item.
|
||||
*/
|
||||
a11yLabel: string;
|
||||
/**
|
||||
* The notification state of the room.
|
||||
*/
|
||||
notificationState: RoomNotificationState;
|
||||
/**
|
||||
* Whether the room should be bolded.
|
||||
*/
|
||||
isBold: boolean;
|
||||
/**
|
||||
* Whether the room is a video room
|
||||
*/
|
||||
isVideoRoom: boolean;
|
||||
/**
|
||||
* The connection state of the call.
|
||||
* `null` if there is no call in the room.
|
||||
*/
|
||||
callConnectionState: ConnectionState | null;
|
||||
/**
|
||||
* Whether there are participants in the call.
|
||||
*/
|
||||
hasParticipantInCall: boolean;
|
||||
/**
|
||||
* Whether the call is a voice or video call.
|
||||
*/
|
||||
callType: CallType | undefined;
|
||||
/**
|
||||
* Pre-rendered and translated preview for the latest message in the room, or undefined
|
||||
* if no preview should be shown.
|
||||
*/
|
||||
messagePreview: string | undefined;
|
||||
/**
|
||||
* Whether the notification decoration should be shown.
|
||||
*/
|
||||
showNotificationDecoration: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* View model for the room list item
|
||||
* @see {@link RoomListItemViewState} for more information about what this view model returns.
|
||||
*/
|
||||
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
|
||||
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
|
||||
const name = useEventEmitterState(room, RoomEvent.Name, () => room.name);
|
||||
|
||||
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
|
||||
|
||||
const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState));
|
||||
const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState(
|
||||
getNotificationValues(notificationState),
|
||||
);
|
||||
useEffect(() => {
|
||||
setA11yLabel(getA11yLabel(name, notificationState));
|
||||
}, [name, notificationState]);
|
||||
|
||||
// Listen to changes in the notification state and update the values
|
||||
useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => {
|
||||
setA11yLabel(getA11yLabel(name, notificationState));
|
||||
setNotificationValues(getNotificationValues(notificationState));
|
||||
});
|
||||
|
||||
// If the notification reference change due to room change, update the values
|
||||
useEffect(() => {
|
||||
setNotificationValues(getNotificationValues(notificationState));
|
||||
}, [notificationState]);
|
||||
|
||||
// We don't want to show the menus if
|
||||
// - there is an invitation for this room
|
||||
// - the user doesn't have access to notification and more options menus
|
||||
const showContextMenu = !invited && hasAccessToOptionsMenu(room);
|
||||
const showHoverMenu =
|
||||
!invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
|
||||
|
||||
const messagePreview = useRoomMessagePreview(room);
|
||||
|
||||
// Video room
|
||||
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
|
||||
// EC video call or video room
|
||||
const call = useCall(room.roomId);
|
||||
const connectionState = useConnectionState(call);
|
||||
const participantCount = useParticipantCount(call);
|
||||
const callConnectionState = call ? connectionState : null;
|
||||
|
||||
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;
|
||||
|
||||
// Actions
|
||||
|
||||
const openRoom = useCallback((): void => {
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "RoomList",
|
||||
});
|
||||
}, [room]);
|
||||
|
||||
const [callType, setCallType] = useState<CallType>(CallType.Video);
|
||||
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);
|
||||
|
||||
return {
|
||||
name,
|
||||
notificationState,
|
||||
showContextMenu,
|
||||
showHoverMenu,
|
||||
openRoom,
|
||||
a11yLabel,
|
||||
isBold,
|
||||
isVideoRoom,
|
||||
callConnectionState,
|
||||
hasParticipantInCall: participantCount > 0,
|
||||
messagePreview,
|
||||
showNotificationDecoration,
|
||||
callType: call ? callType : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the values from the notification state
|
||||
* @param notificationState
|
||||
*/
|
||||
function getNotificationValues(notificationState: RoomNotificationState): {
|
||||
computeA11yLabel: (name: string) => string;
|
||||
isBold: boolean;
|
||||
invited: boolean;
|
||||
hasVisibleNotification: boolean;
|
||||
} {
|
||||
const invited = notificationState.invited;
|
||||
const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState);
|
||||
const isBold = notificationState.hasAnyNotificationOrActivity;
|
||||
|
||||
const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted;
|
||||
|
||||
return {
|
||||
computeA11yLabel,
|
||||
isBold,
|
||||
invited,
|
||||
hasVisibleNotification,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the a11y label for the room list item
|
||||
* @param roomName
|
||||
* @param notificationState
|
||||
*/
|
||||
function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string {
|
||||
if (notificationState.isUnsentMessage) {
|
||||
return _t("a11y|room_messsage_not_sent", {
|
||||
roomName,
|
||||
});
|
||||
} else if (notificationState.invited) {
|
||||
return _t("a11y|room_n_unread_invite", {
|
||||
roomName,
|
||||
});
|
||||
} else if (notificationState.isMention) {
|
||||
return _t("a11y|room_n_unread_messages_mentions", {
|
||||
roomName,
|
||||
count: notificationState.count,
|
||||
});
|
||||
} else if (notificationState.hasUnreadCount) {
|
||||
return _t("a11y|room_n_unread_messages", {
|
||||
roomName,
|
||||
count: notificationState.count,
|
||||
});
|
||||
} else {
|
||||
return _t("room_list|room|open_room", { roomName });
|
||||
}
|
||||
}
|
||||
|
||||
function useRoomMessagePreview(room: Room): string | undefined {
|
||||
const { shouldShowMessagePreview } = useMessagePreviewToggle();
|
||||
const [previewText, setPreviewText] = useState<string | undefined>(undefined);
|
||||
|
||||
const updatePreview = useCallback(async () => {
|
||||
if (!shouldShowMessagePreview) {
|
||||
setPreviewText(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
// For the tag, we only care about whether the room is a DM or not as we don't show
|
||||
// display names in previewsd for DMs, so anything else we just say is 'untagged'
|
||||
// (even though it could actually be have other tags: we don't care about them).
|
||||
const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom(
|
||||
room,
|
||||
roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged,
|
||||
);
|
||||
setPreviewText(messagePreview?.text);
|
||||
}, [room, shouldShowMessagePreview]);
|
||||
|
||||
// MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter
|
||||
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updatePreview();
|
||||
}, [updatePreview]);
|
||||
|
||||
return previewText;
|
||||
}
|
||||
61
src/components/viewmodels/roomlist/RoomListPanelViewModel.ts
Normal file
61
src/components/viewmodels/roomlist/RoomListPanelViewModel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
337
src/components/viewmodels/roomlist/RoomListViewModel.ts
Normal file
337
src/components/viewmodels/roomlist/RoomListViewModel.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
118
src/components/viewmodels/roomlist/RoomListViewViewModel.ts
Normal file
118
src/components/viewmodels/roomlist/RoomListViewViewModel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
120
src/components/viewmodels/roomlist/SpaceMenuViewModel.ts
Normal file
120
src/components/viewmodels/roomlist/SpaceMenuViewModel.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
|
||||
interface MessagePreviewToggleState {
|
||||
shouldShowMessagePreview: boolean;
|
||||
toggleMessagePreview: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook:
|
||||
* - Provides a state that tracks whether message previews are turned on or off.
|
||||
* - Provides a function to toggle message previews.
|
||||
*/
|
||||
export function useMessagePreviewToggle(): MessagePreviewToggleState {
|
||||
const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview");
|
||||
|
||||
const toggleMessagePreview = useCallback((): void => {
|
||||
const toggled = !shouldShowMessagePreview;
|
||||
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
|
||||
}, [shouldShowMessagePreview]);
|
||||
|
||||
return { toggleMessagePreview, shouldShowMessagePreview };
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
|
||||
/**
|
||||
* Hook to navigate the room list using keyboard shortcuts.
|
||||
* It listens to the ViewRoomDelta action and updates the room list accordingly.
|
||||
* @param rooms
|
||||
*/
|
||||
export function useRoomListNavigation(rooms: Room[]): void {
|
||||
useDispatcher(dispatcher, (payload) => {
|
||||
if (payload.action !== Action.ViewRoomDelta) return;
|
||||
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
if (!roomId) return;
|
||||
|
||||
const { delta, unread } = payload as ViewRoomDeltaPayload;
|
||||
const filteredRooms = unread
|
||||
? // Filter the rooms to only include unread ones and the active room
|
||||
rooms.filter((room) => {
|
||||
const state = RoomNotificationStateStore.instance.getRoomState(room);
|
||||
return room.roomId === roomId || state.isUnread;
|
||||
})
|
||||
: rooms;
|
||||
|
||||
const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// Get the next/previous new room according to the delta
|
||||
// Use slice to loop on the list
|
||||
// If delta is -1 at the start of the list, it will go to the end
|
||||
// If delta is 1 at the end of the list, it will go to the start
|
||||
const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length);
|
||||
if (!newRoom) return;
|
||||
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: newRoom.roomId,
|
||||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,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!],
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type PropsWithChildren } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
||||
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
|
||||
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
|
||||
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";
|
||||
|
||||
interface EmptyRoomListProps {
|
||||
/**
|
||||
* The view model for the room list
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state for the room list
|
||||
*/
|
||||
export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefined {
|
||||
// If there is no active primary filter, show the default empty state
|
||||
if (!vm.activePrimaryFilter) return <DefaultPlaceholder vm={vm} />;
|
||||
|
||||
switch (vm.activePrimaryFilter.key) {
|
||||
case FilterKey.FavouriteFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_favourites")}
|
||||
description={_t("room_list|empty|no_favourites_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.PeopleFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_people")}
|
||||
description={_t("room_list|empty|no_people_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.RoomsFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_rooms")}
|
||||
description={_t("room_list|empty|no_rooms_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.UnreadFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_unread")}
|
||||
action={_t("room_list|empty|show_chats")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
case FilterKey.InvitesFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_invites")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
case FilterKey.MentionsFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_mentions")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
case FilterKey.LowPriorityFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_lowpriority")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface GenericPlaceholderProps {
|
||||
/**
|
||||
* The title of the placeholder
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The description of the placeholder
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic placeholder for the room list
|
||||
*/
|
||||
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
|
||||
return (
|
||||
<Flex
|
||||
data-testid="empty-room-list"
|
||||
className="mx_EmptyRoomList_GenericPlaceholder"
|
||||
direction="column"
|
||||
align="stretch"
|
||||
justify="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
>
|
||||
<span className="mx_EmptyRoomList_GenericPlaceholder_title">{title}</span>
|
||||
{description && <span className="mx_EmptyRoomList_GenericPlaceholder_description">{description}</span>}
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefaultPlaceholderProps {
|
||||
/**
|
||||
* The view model for the room list
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default empty state for the room list when no primary filter is active
|
||||
* The user can create chat or room (if they have the permission)
|
||||
*/
|
||||
function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_chats")}
|
||||
description={
|
||||
vm.canCreateRoom
|
||||
? _t("room_list|empty|no_chats_description")
|
||||
: _t("room_list|empty|no_chats_description_no_room_rights")
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
className="mx_EmptyRoomList_DefaultPlaceholder"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="var(--cpd-space-4x)"
|
||||
>
|
||||
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
|
||||
{_t("action|start_chat")}
|
||||
</Button>
|
||||
{vm.canCreateRoom && (
|
||||
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
|
||||
{_t("action|new_room")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionPlaceholderProps {
|
||||
filter: PrimaryFilter;
|
||||
title: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder for the room list when a filter is active
|
||||
* The user can take action to toggle the filter
|
||||
*/
|
||||
function ActionPlaceholder({ filter, title, action }: ActionPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<GenericPlaceholder title={title}>
|
||||
<Button kind="tertiary" onClick={filter.toggle}>
|
||||
{action}
|
||||
</Button>
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
@ -1,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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user