/* Copyright 2024 New Vector Ltd. Copyright 2015-2018 , 2020, 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { EventType, RoomType, Room } from "matrix-js-sdk/src/matrix"; import React, { ComponentType, createRef, ReactComponentElement, SyntheticEvent } from "react"; import { IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { Action } from "../../../dispatcher/actions"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { ActionPayload } from "../../../dispatcher/payloads"; import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { _t, _td, TranslationKey } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import PosthogTrackers from "../../../PosthogTrackers"; import SettingsStore from "../../../settings/SettingsStore"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import { UIComponent } from "../../../settings/UIFeature"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import { isMetaSpace, ISuggestedRoom, MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE, UPDATE_SUGGESTED_ROOMS, } from "../../../stores/spaces"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays"; import { objectShallowClone, objectWithOnly } from "../../../utils/objects"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import { shouldShowSpaceInvite, showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space"; import { ChevronFace, ContextMenuTooltipButton, MenuProps, useContextMenu } from "../../structures/ContextMenu"; import RoomAvatar from "../avatars/RoomAvatar"; import { BetaPill } from "../beta/BetaCard"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList, } from "../context_menus/IconizedContextMenu"; import ExtraTile from "./ExtraTile"; import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import AccessibleButton from "../elements/AccessibleButton"; import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; onFocus: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void; onResize: () => void; onListCollapse?: (isExpanded: boolean) => void; resizeNotifier: ResizeNotifier; isMinimized: boolean; activeSpace: SpaceKey; } interface IState { sublists: ITagMap; currentRoomId?: string; suggestedRooms: ISuggestedRoom[]; } export const TAG_ORDER: TagID[] = [ DefaultTagID.Invite, DefaultTagID.Favourite, DefaultTagID.DM, DefaultTagID.Untagged, DefaultTagID.Conference, DefaultTagID.LowPriority, DefaultTagID.ServerNotice, DefaultTagID.Suggested, ]; const ALWAYS_VISIBLE_TAGS: TagID[] = [DefaultTagID.DM, DefaultTagID.Untagged]; interface ITagAesthetics { sectionLabel: TranslationKey; sectionLabelRaw?: string; AuxButtonComponent?: ComponentType; isInvite: boolean; defaultHidden: boolean; } type TagAestheticsMap = Partial<{ [tagId in TagID]: ITagAesthetics; }>; const auxButtonContextMenuPosition = (handle: HTMLDivElement): MenuProps => { const rect = handle.getBoundingClientRect(); return { chevronFace: ChevronFace.None, left: rect.left - 7, top: rect.top + rect.height, }; }; const DmAuxButton: React.FC = ({ tabIndex, dispatcher = defaultDispatcher }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => { return SpaceStore.instance.activeSpaceRoom; }); const showCreateRooms = shouldShowComponent(UIComponent.CreateRooms); const showInviteUsers = shouldShowComponent(UIComponent.InviteUsers); if (activeSpace && (showCreateRooms || showInviteUsers)) { let contextMenu: JSX.Element | undefined; if (menuDisplayed && handle.current) { const canInvite = shouldShowSpaceInvite(activeSpace); contextMenu = ( {showCreateRooms && ( { e.preventDefault(); e.stopPropagation(); closeMenu(); defaultDispatcher.dispatch({ action: "view_create_chat" }); PosthogTrackers.trackInteraction( "WebRoomListRoomsSublistPlusMenuCreateChatItem", e, ); }} /> )} {showInviteUsers && ( { e.preventDefault(); e.stopPropagation(); closeMenu(); showSpaceInvite(activeSpace); }} disabled={!canInvite} title={canInvite ? undefined : _t("spaces|error_no_permission_invite")} /> )} ); } return ( <> {contextMenu} ); } else if (!activeSpace && showCreateRooms) { return ( { dispatcher.dispatch({ action: "view_create_chat" }); PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e); }} className="mx_RoomSublist_auxButton" aria-label={_t("action|start_chat")} title={_t("action|start_chat")} /> ); } return null; }; const UntaggedAuxButton: React.FC = ({ tabIndex }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => { return SpaceStore.instance.activeSpaceRoom; }); const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms); const showExploreRooms = shouldShowComponent(UIComponent.ExploreRooms); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms"); let contextMenuContent: JSX.Element | undefined; if (menuDisplayed && activeSpace) { const canAddRooms = activeSpace.currentState.maySendStateEvent( EventType.SpaceChild, MatrixClientPeg.safeGet().getSafeUserId(), ); contextMenuContent = ( { e.preventDefault(); e.stopPropagation(); closeMenu(); defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: activeSpace.roomId, metricsTrigger: undefined, // other }); PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e); }} /> {showCreateRoom ? ( <> { e.preventDefault(); e.stopPropagation(); closeMenu(); showCreateNewRoom(activeSpace); PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e); }} disabled={!canAddRooms} title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")} /> {videoRoomsEnabled && ( { e.preventDefault(); e.stopPropagation(); closeMenu(); showCreateNewRoom( activeSpace, elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, ); }} disabled={!canAddRooms} title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")} > )} { e.preventDefault(); e.stopPropagation(); closeMenu(); showAddExistingRooms(activeSpace); }} disabled={!canAddRooms} title={canAddRooms ? undefined : _t("spaces|error_no_permission_add_room")} /> ) : null} ); } else if (menuDisplayed) { contextMenuContent = ( {showCreateRoom && ( <> { e.preventDefault(); e.stopPropagation(); closeMenu(); defaultDispatcher.dispatch({ action: "view_create_room" }); PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e); }} /> {videoRoomsEnabled && ( { e.preventDefault(); e.stopPropagation(); closeMenu(); defaultDispatcher.dispatch({ action: "view_create_room", type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, }); }} > )} )} {showExploreRooms ? ( { e.preventDefault(); e.stopPropagation(); closeMenu(); PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e); defaultDispatcher.fire(Action.ViewRoomDirectory); }} /> ) : null} ); } let contextMenu: JSX.Element | null = null; if (menuDisplayed && handle.current) { contextMenu = ( {contextMenuContent} ); } if (showCreateRoom || showExploreRooms) { return ( <> {contextMenu} ); } return null; }; const TAG_AESTHETICS: TagAestheticsMap = { [DefaultTagID.Invite]: { sectionLabel: _td("action|invites_list"), isInvite: true, defaultHidden: false, }, [DefaultTagID.Favourite]: { sectionLabel: _td("common|favourites"), isInvite: false, defaultHidden: false, }, [DefaultTagID.DM]: { sectionLabel: _td("common|people"), isInvite: false, defaultHidden: false, AuxButtonComponent: DmAuxButton, }, [DefaultTagID.Conference]: { sectionLabel: _td("voip|metaspace_video_rooms|conference_room_section"), isInvite: false, defaultHidden: false, }, [DefaultTagID.Untagged]: { sectionLabel: _td("common|rooms"), isInvite: false, defaultHidden: false, AuxButtonComponent: UntaggedAuxButton, }, [DefaultTagID.LowPriority]: { sectionLabel: _td("common|low_priority"), isInvite: false, defaultHidden: false, }, [DefaultTagID.ServerNotice]: { sectionLabel: _td("common|system_alerts"), isInvite: false, defaultHidden: false, }, [DefaultTagID.Suggested]: { sectionLabel: _td("room_list|suggested_rooms_heading"), isInvite: false, defaultHidden: false, }, }; export default class RoomList extends React.PureComponent { private dispatcherRef?: string; private treeRef = createRef(); public static contextType = MatrixClientContext; declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); this.state = { sublists: {}, suggestedRooms: SpaceStore.instance.suggestedRooms, }; } public componentDidMount(): void { this.dispatcherRef = defaultDispatcher.register(this.onAction); SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); this.updateLists(); // trigger the first update } public componentWillUnmount(): void { SpaceStore.instance.off(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); defaultDispatcher.unregister(this.dispatcherRef); SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); } private onRoomViewStoreUpdate = (): void => { this.setState({ currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined, }); }; private onAction = (payload: ActionPayload): void => { if (payload.action === Action.ViewRoomDelta) { const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload; const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (!currentRoomId) return; const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread); if (room) { defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, show_room_tile: true, // to make sure the room gets scrolled into view metricsTrigger: "WebKeyboardShortcut", metricsViaKeyboard: true, }); } } else if (payload.action === Action.PstnSupportUpdated) { this.updateLists(); } }; private getRoomDelta = (roomId: string, delta: number, unread = false): Room => { const lists = RoomListStore.instance.orderedLists; const rooms: Room[] = []; TAG_ORDER.forEach((t) => { let listRooms = lists[t]; if (unread) { // filter to only notification rooms (and our current active room so we can index properly) listRooms = listRooms.filter((r) => { const state = RoomNotificationStateStore.instance.getRoomState(r); return state.room.roomId === roomId || state.isUnread; }); } rooms.push(...listRooms); }); const currentIndex = rooms.findIndex((r) => r.roomId === roomId); // use slice to account for looping around the start const [room] = rooms.slice((currentIndex + delta) % rooms.length); return room; }; private updateSuggestedRooms = (suggestedRooms: ISuggestedRoom[]): void => { this.setState({ suggestedRooms }); }; private updateLists = (): void => { const newLists = RoomListStore.instance.orderedLists; const previousListIds = Object.keys(this.state.sublists); const newListIds = Object.keys(newLists); let doUpdate = arrayHasDiff(previousListIds, newListIds); if (!doUpdate) { // so we didn't have the visible sublists change, but did the contents of those // sublists change significantly enough to break the sticky headers? Probably, so // let's check the length of each. for (const tagId of newListIds) { const oldRooms = this.state.sublists[tagId]; const newRooms = newLists[tagId]; if (oldRooms.length !== newRooms.length) { doUpdate = true; break; } } } if (doUpdate) { // We have to break our reference to the room list store if we want to be able to // diff the object for changes, so do that. // @ts-ignore - ITagMap is ts-ignored so this will have to be too const newSublists = objectWithOnly(newLists, newListIds); const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v)); this.setState({ sublists }, () => { this.props.onResize(); }); } }; private renderSuggestedRooms(): ReactComponentElement[] { return this.state.suggestedRooms.map((room) => { const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("empty_room"); const avatar = ( ); const viewRoom = (ev: SyntheticEvent): void => { defaultDispatcher.dispatch({ action: Action.ViewRoom, room_alias: room.canonical_alias || room.aliases?.[0], room_id: room.room_id, via_servers: room.viaServers, oob_data: { avatarUrl: room.avatar_url, name, }, metricsTrigger: "RoomList", metricsViaKeyboard: ev.type !== "click", }); }; return ( ); }); } private renderSublists(): React.ReactElement[] { // show a skeleton UI if the user is in no rooms and they are not filtering and have no suggested rooms const showSkeleton = !this.state.suggestedRooms?.length && Object.values(RoomListStore.instance.orderedLists).every((list) => !list?.length); return TAG_ORDER.map((orderedTagId) => { let extraTiles: ReactComponentElement[] | undefined; if (orderedTagId === DefaultTagID.Suggested) { extraTiles = this.renderSuggestedRooms(); } const aesthetics = TAG_AESTHETICS[orderedTagId]; if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); let alwaysVisible = ALWAYS_VISIBLE_TAGS.includes(orderedTagId); if ( (this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) || (this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) || (this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) || (this.props.activeSpace === MetaSpace.VideoRooms && orderedTagId === DefaultTagID.DM) || (!isMetaSpace(this.props.activeSpace) && orderedTagId === DefaultTagID.DM && !SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace)) ) { alwaysVisible = false; } let forceExpanded = false; if ( (this.props.activeSpace === MetaSpace.Favourites && orderedTagId === DefaultTagID.Favourite) || (this.props.activeSpace === MetaSpace.People && orderedTagId === DefaultTagID.DM) ) { forceExpanded = true; } // The cost of mounting/unmounting this component offsets the cost // of keeping it in the DOM and hiding it when it is not required return ( ); }); } public focus(): void { // focus the first focusable element in this aria treeview widget const treeItems = this.treeRef.current?.querySelectorAll('[role="treeitem"]'); if (!treeItems) return; [...treeItems].find((e) => e.offsetParent !== null)?.focus(); } public render(): React.ReactNode { const sublists = this.renderSublists(); return ( {({ onKeyDownHandler }) => (
{ 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; } onKeyDownHandler(ev); }} className="mx_RoomList" role="tree" aria-label={_t("common|rooms")} ref={this.treeRef} > {sublists}
)}
); } }