diff --git a/src/components/viewmodels/roomlist/ComposeMenuViewModel.ts b/src/components/viewmodels/roomlist/ComposeMenuViewModel.ts new file mode 100644 index 0000000000..a34f466559 --- /dev/null +++ b/src/components/viewmodels/roomlist/ComposeMenuViewModel.ts @@ -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 { + 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, + }); + } + }; +} diff --git a/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx b/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx deleted file mode 100644 index 9e141c1379..0000000000 --- a/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx +++ /dev/null @@ -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(null); - - const updatePreview = useCallback(async (): Promise => { - /** - * 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, - }; -} diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.ts b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.ts new file mode 100644 index 0000000000..1c9e349c11 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.ts @@ -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(); + } +} diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx deleted file mode 100644 index 451a4898b7..0000000000 --- a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx +++ /dev/null @@ -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({ - 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, - }; -} diff --git a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx deleted file mode 100644 index 738a05b8c3..0000000000 --- a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx +++ /dev/null @@ -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 => { - await clearRoomNotification(room, matrixClient); - PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt); - }, - [room, matrixClient], - ); - - const markAsUnread = useCallback( - async (evt: Event): Promise => { - 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, - }; -} diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx deleted file mode 100644 index 30576e2dc2..0000000000 --- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx +++ /dev/null @@ -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({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: "RoomList", - }); - }, [room]); - - const [callType, setCallType] = useState(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(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; -} diff --git a/src/components/viewmodels/roomlist/RoomListPanelViewModel.ts b/src/components/viewmodels/roomlist/RoomListPanelViewModel.ts new file mode 100644 index 0000000000..99d8a61ea9 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListPanelViewModel.ts @@ -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 { + 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(); + } +} diff --git a/src/components/viewmodels/roomlist/RoomListPrimaryFiltersViewModel.ts b/src/components/viewmodels/roomlist/RoomListPrimaryFiltersViewModel.ts new file mode 100644 index 0000000000..65759d140e --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListPrimaryFiltersViewModel.ts @@ -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 = 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()); + } +} diff --git a/src/components/viewmodels/roomlist/RoomListSearchViewModel.ts b/src/components/viewmodels/roomlist/RoomListSearchViewModel.ts new file mode 100644 index 0000000000..db7b40309f --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListSearchViewModel.ts @@ -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); + }; +} diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.ts b/src/components/viewmodels/roomlist/RoomListViewModel.ts new file mode 100644 index 0000000000..6911ff8676 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListViewModel.ts @@ -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 + implements Omit { + + 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({ + 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({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: "RoomList", + }); + }; + + public onMarkAsRead = async (roomId: string): Promise => { + 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 => { + 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); + }; +} diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx deleted file mode 100644 index a48d973b23..0000000000 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ /dev/null @@ -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( - 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, - }; -} diff --git a/src/components/viewmodels/roomlist/RoomListViewViewModel.ts b/src/components/viewmodels/roomlist/RoomListViewViewModel.ts new file mode 100644 index 0000000000..f028d07388 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListViewViewModel.ts @@ -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(); + } +} diff --git a/src/components/viewmodels/roomlist/SortOptionsMenuViewModel.ts b/src/components/viewmodels/roomlist/SortOptionsMenuViewModel.ts new file mode 100644 index 0000000000..417257e5f5 --- /dev/null +++ b/src/components/viewmodels/roomlist/SortOptionsMenuViewModel.ts @@ -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); + }; +} diff --git a/src/components/viewmodels/roomlist/SpaceMenuViewModel.ts b/src/components/viewmodels/roomlist/SpaceMenuViewModel.ts new file mode 100644 index 0000000000..b963354239 --- /dev/null +++ b/src/components/viewmodels/roomlist/SpaceMenuViewModel.ts @@ -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 { + 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({ + 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); + }; +} diff --git a/src/components/viewmodels/roomlist/useFilteredRooms.tsx b/src/components/viewmodels/roomlist/useFilteredRooms.tsx deleted file mode 100644 index 4e311f39db..0000000000 --- a/src/components/viewmodels/roomlist/useFilteredRooms.tsx +++ /dev/null @@ -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 = 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(); - - 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, - }; -} diff --git a/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx b/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx deleted file mode 100644 index efb58b3e04..0000000000 --- a/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx +++ /dev/null @@ -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 }; -} diff --git a/src/components/viewmodels/roomlist/useRoomListNavigation.ts b/src/components/viewmodels/roomlist/useRoomListNavigation.ts deleted file mode 100644 index 5ef979e79c..0000000000 --- a/src/components/viewmodels/roomlist/useRoomListNavigation.ts +++ /dev/null @@ -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({ - action: Action.ViewRoom, - room_id: newRoom.roomId, - show_room_tile: true, // to make sure the room gets scrolled into view - metricsTrigger: "WebKeyboardShortcut", - metricsViaKeyboard: true, - }); - }); -} diff --git a/src/components/viewmodels/roomlist/useSorter.ts b/src/components/viewmodels/roomlist/useSorter.ts deleted file mode 100644 index c7a880d430..0000000000 --- a/src/components/viewmodels/roomlist/useSorter.ts +++ /dev/null @@ -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!], - }; -} diff --git a/src/components/viewmodels/roomlist/useStickyRoomList.tsx b/src/components/viewmodels/roomlist/useStickyRoomList.tsx deleted file mode 100644 index ad8a72b8b0..0000000000 --- a/src/components/viewmodels/roomlist/useStickyRoomList.tsx +++ /dev/null @@ -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): 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({ - 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; -} diff --git a/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx b/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx deleted file mode 100644 index 8c1d04b8c5..0000000000 --- a/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx +++ /dev/null @@ -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 ; - - switch (vm.activePrimaryFilter.key) { - case FilterKey.FavouriteFilter: - return ( - - ); - case FilterKey.PeopleFilter: - return ( - - ); - case FilterKey.RoomsFilter: - return ( - - ); - case FilterKey.UnreadFilter: - return ( - - ); - case FilterKey.InvitesFilter: - return ( - - ); - case FilterKey.MentionsFilter: - return ( - - ); - case FilterKey.LowPriorityFilter: - return ( - - ); - 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): JSX.Element { - return ( - - {title} - {description && {description}} - {children} - - ); -} - -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 ( - - - - {vm.canCreateRoom && ( - - )} - - - ); -} - -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 ( - - - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomList.tsx b/src/components/views/rooms/RoomListPanel/RoomList.tsx deleted file mode 100644 index af3ccfdd19..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomList.tsx +++ /dev/null @@ -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 = 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 ( - 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 ( - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx b/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx deleted file mode 100644 index 6519328ab5..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx +++ /dev/null @@ -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 ( - - -

{vm.title}

- {vm.displaySpaceMenu && } -
- - -
- -
-
- - {/* If we don't display the compose menu, it means that the user can only send DM */} - -
- {vm.displayComposeMenu ? ( - - ) : ( - vm.createChatRoom(e.nativeEvent)} - tooltip={_t("action|new_conversation")} - > - - - )} -
-
-
-
- ); -} - -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 ( - - - - } - > - - {vm.canInviteInSpace && ( - - )} - - {vm.canAccessSpaceSettings && ( - - )} - - ); -} - -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 ( - - - - } - > - - {vm.canCreateRoom && ( - - )} - {vm.canCreateVideoRoom && ( - - )} - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx deleted file mode 100644 index 0769d9e40a..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx +++ /dev/null @@ -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): JSX.Element { - const vm = useRoomListItemMenuViewModel(room); - - return ( - - - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx deleted file mode 100644 index ad92559f5c..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx +++ /dev/null @@ -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 ( - - {vm.showMoreOptionsMenu && } - {vm.showNotificationMenu && } - - ); -} - -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 ( - { - setOpen(isOpen); - setMenuOpen(isOpen); - }} - title={_t("room_list|room|more_options")} - showTitle={false} - align="start" - trigger={} - > - - - ); -} - -interface MoreOptionContentProps { - /** - * The view model state for the menu. - */ - vm: RoomListItemMenuViewState; -} - -export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { - return ( -
e.stopPropagation()} - > - {vm.canMarkAsRead && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - {vm.canMarkAsUnread && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - evt.stopPropagation()} - /> - evt.stopPropagation()} - /> - {vm.canInvite && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - {vm.canCopyRoomLink && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - - evt.stopPropagation()} - hideChevron={true} - /> -
- ); -} - -interface MoreOptionsButtonProps extends ComponentProps { - ref?: Ref; -} - -/** - * A button to trigger the more options menu. - */ -const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element { - return ( - - - - - - ); -}; - -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 = ; - - return ( -
e.stopPropagation()} - > - { - setOpen(isOpen); - setMenuOpen(isOpen); - }} - title={_t("room_list|notification_options")} - showTitle={false} - align="start" - trigger={} - > - vm.setRoomNotifState(RoomNotifState.AllMessages)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationAllMessage && checkComponent} - - vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationAllMessageLoud && checkComponent} - - vm.setRoomNotifState(RoomNotifState.MentionsOnly)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationMentionOnly && checkComponent} - - vm.setRoomNotifState(RoomNotifState.Mute)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationMute && checkComponent} - - -
- ); -} - -interface NotificationButtonProps extends ComponentProps { - /** - * Whether the room is muted. - */ - isRoomMuted: boolean; - ref?: Ref; -} - -/** - * A button to trigger the notification menu. - */ -const NotificationButton = function MoreOptionsButton({ - isRoomMuted, - ref, - ...props -}: NotificationButtonProps): JSX.Element { - return ( - - - {isRoomMuted ? : } - - - ); -}; diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx deleted file mode 100644 index ac1396c8ec..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx +++ /dev/null @@ -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, "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 = ; - - // Create the notification decoration component - const notificationDecoration = vm.showNotificationDecoration ? ( - - ) : null; - - // Create the hover menu component - const hoverMenu = vm.showHoverMenu ? {}} /> : null; - - // Create the context menu wrapper function - const contextMenuWrapper = vm.showContextMenu - ? (content: ReactNode) => ( - {}}> - {content} - - ) - : undefined; - - return ( - - ); -}); diff --git a/src/components/views/rooms/RoomListPanel/RoomListOptionsMenu.tsx b/src/components/views/rooms/RoomListPanel/RoomListOptionsMenu.tsx deleted file mode 100644 index d851ca34b5..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListOptionsMenu.tsx +++ /dev/null @@ -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 { - ref?: Ref; -} - -const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => ( - - - - - -); - -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 ( - } - > - - - - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index e459c50d12..0b38a6feda 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -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 = ({ activeSpace }) => { - const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); + const client = useMatrixClientContext(); const [focusedElement, setFocusedElement] = useState(null); + // Create ViewModel instance - use ref to survive strict mode double-mounting + const vmRef = useRef(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 = ({ 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 ; + }, + [client], + ); + return ( : undefined} - headerSlot={} - contentSlot={} + vm={vm} + renderAvatar={renderAvatar} onFocus={onFocus} onBlur={onBlur} onKeyDown={onKeyDown} diff --git a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx b/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx deleted file mode 100644 index 44f19a86da..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx +++ /dev/null @@ -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(isExpanded); - const filters = useVisibleFilters(vm.primaryFilters, wrappingIndex); - - return ( - - {displayChevron && ( - setIsExpanded((_expanded) => !_expanded)} - > - - - )} - - {filters.map((filter, i) => ( - filter.toggle()}> - {filter.name} - - ))} - - - ); -} - -/** - * 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( - isExpanded: boolean, -): { ref: RefObject; isWrapping: boolean; wrappingIndex: number } { - const ref = useRef(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; -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx b/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx deleted file mode 100644 index f1c3c2e66d..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx +++ /dev/null @@ -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 ( - - - {displayDialButton && ( -