From 49764d1a83ca2a59816ebc2aa5f8c5b586a18715 Mon Sep 17 00:00:00 2001 From: Badi ifaoui Date: Tue, 30 Jan 2024 10:28:28 +0100 Subject: [PATCH] feat: Group TG and community rooms in sidebar --- components.json | 4 +- src/components/structures/LeftPanel.tsx | 2 +- src/components/views/rooms/RoomList.tsx | 682 +++++++++++++++ src/components/views/rooms/RoomSublist.tsx | 918 +++++++++++++++++++++ src/stores/room-list/custom-models.ts | 7 + 5 files changed, 1611 insertions(+), 2 deletions(-) create mode 100644 src/components/views/rooms/RoomList.tsx create mode 100644 src/components/views/rooms/RoomSublist.tsx create mode 100644 src/stores/room-list/custom-models.ts diff --git a/components.json b/components.json index 947fe648f4..7be3a55799 100644 --- a/components.json +++ b/components.json @@ -18,5 +18,7 @@ "src/components/structures/HomePage.tsx": "src/components/structures/HomePage.tsx", "src/components/views/dialogs/spotlight/SpotlightDialog.tsx": "src/components/views/dialogs/spotlight/SpotlightDialog.tsx", "src/components/views/elements/Pill.tsx": "src/components/views/elements/Pill.tsx", - "src/components/structures/LeftPanel.tsx": "src/components/structures/LeftPanel.tsx" + "src/components/structures/LeftPanel.tsx": "src/components/structures/LeftPanel.tsx", + "src/components/views/rooms/RoomList.tsx": "src/components/views/rooms/RoomList.tsx", + "src/components/views/rooms/RoomSublist.tsx": "src/components/views/rooms/RoomSublist.tsx" } diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index f7f6eea716..8cbc3878ef 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -19,7 +19,6 @@ import { createRef } from "react"; import classNames from "classnames"; import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; import { _t } from "matrix-react-sdk/src/languageHandler"; -import RoomList from "matrix-react-sdk/src/components/views/rooms/RoomList"; import LegacyCallHandler from "matrix-react-sdk/src/LegacyCallHandler"; import { HEADER_HEIGHT } from "matrix-react-sdk/src/components/views/rooms/RoomSublist"; import { Action } from "matrix-react-sdk/src/dispatcher/actions"; @@ -45,6 +44,7 @@ import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers"; import PageType from "matrix-react-sdk/src/PageTypes"; import { UserOnboardingButton } from "matrix-react-sdk/src/components/views/user-onboarding/UserOnboardingButton"; +import RoomList from "../views/rooms/RoomList"; import { Icon as Superhero } from "../../../res/themes/superhero/img/logos/superhero.svg"; interface IProps { diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx new file mode 100644 index 0000000000..05d9dc172d --- /dev/null +++ b/src/components/views/rooms/RoomList.tsx @@ -0,0 +1,682 @@ +/* +Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventType, Room, RoomType } from "matrix-js-sdk/src/matrix"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers"; +import { + IState as IRovingTabIndexState, + RovingTabIndexProvider, +} from "matrix-react-sdk/src/accessibility/RovingTabIndex"; +import { + ChevronFace, + ContextMenuTooltipButton, + MenuProps, + useContextMenu, +} from "matrix-react-sdk/src/components/structures/ContextMenu"; +import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar"; +import { BetaPill } from "matrix-react-sdk/src/components/views/beta/BetaCard"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "matrix-react-sdk/src/components/views/context_menus/IconizedContextMenu"; +import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton"; +import ExtraTile from "matrix-react-sdk/src/components/views/rooms/ExtraTile"; +import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext"; +import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads"; +import { ViewRoomDeltaPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomDeltaPayload"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { useEventEmitterState } from "matrix-react-sdk/src/hooks/useEventEmitter"; +import { useFeatureEnabled } from "matrix-react-sdk/src/hooks/useSettings"; +import { TranslationKey, _t, _td } from "matrix-react-sdk/src/languageHandler"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature"; +import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "matrix-react-sdk/src/stores/room-list/RoomListStore"; +import { ITagMap } from "matrix-react-sdk/src/stores/room-list/algorithms/models"; +import { DefaultTagID, TagID } from "matrix-react-sdk/src/stores/room-list/models"; +import { + ISuggestedRoom, + MetaSpace, + SpaceKey, + UPDATE_SELECTED_SPACE, + UPDATE_SUGGESTED_ROOMS, + isMetaSpace, +} from "matrix-react-sdk/src/stores/spaces"; +import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore"; +import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier"; +import { arrayFastClone, arrayHasDiff } from "matrix-react-sdk/src/utils/arrays"; +import { objectShallowClone, objectWithOnly } from "matrix-react-sdk/src/utils/objects"; +import { + shouldShowSpaceInvite, + showAddExistingRooms, + showCreateNewRoom, + showSpaceInvite, +} from "matrix-react-sdk/src/utils/space"; +import React, { ComponentType, ReactComponentElement, SyntheticEvent, createRef } from "react"; +import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore"; + +import { CustomTagID, SuperheroTagID } from "../../../stores/room-list/custom-models"; +import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; + +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: CustomTagID[] = [ + DefaultTagID.Invite, + DefaultTagID.Favourite, + DefaultTagID.DM, + DefaultTagID.Untagged, + DefaultTagID.LowPriority, + DefaultTagID.ServerNotice, + DefaultTagID.Suggested, + DefaultTagID.Archived, + SuperheroTagID.CommunityRooms, +]; +const ALWAYS_VISIBLE_TAGS: CustomTagID[] = [DefaultTagID.DM, DefaultTagID.Untagged, SuperheroTagID.CommunityRooms]; + +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} + tooltip={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} + tooltip={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")} + /> + {videoRoomsEnabled && ( + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + showCreateNewRoom( + activeSpace, + elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, + ); + }} + disabled={!canAddRooms} + tooltip={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")} + > + + + )} + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + showAddExistingRooms(activeSpace); + }} + disabled={!canAddRooms} + tooltip={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.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, + }, + + // TODO: Replace with archived view: https://github.com/vector-im/element-web/issues/14038 + [DefaultTagID.Archived]: { + sectionLabel: _td("common|historical"), + isInvite: true, + defaultHidden: true, + }, + + [DefaultTagID.Suggested]: { + sectionLabel: _td("room_list|suggested_rooms_heading"), + isInvite: false, + defaultHidden: false, + }, + [SuperheroTagID.CommunityRooms]: { + sectionLabel: _td("room_list|suggested_rooms_heading"), + sectionLabelRaw: "Community Rooms", + isInvite: false, + defaultHidden: false, + AuxButtonComponent: DmAuxButton, + }, +}; + +export default class RoomList extends React.PureComponent { + private dispatcherRef?: string; + private treeRef = createRef(); + + public static contextType = MatrixClientContext; + public context!: React.ContextType; + + public constructor(props: IProps) { + super(props); + + 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); + if (this.dispatcherRef) 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. + 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 === SuperheroTagID.CommunityRooms && + orderedTagId === SuperheroTagID.CommunityRooms) || + (!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) || + (this.props.activeSpace === SuperheroTagID.CommunityRooms && + orderedTagId !== SuperheroTagID.CommunityRooms) + ) { + 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 }) => ( +
+ {sublists} +
+ )} +
+ ); + } +} diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx new file mode 100644 index 0000000000..e5e4d0d3fe --- /dev/null +++ b/src/components/views/rooms/RoomSublist.tsx @@ -0,0 +1,918 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017, 2018 Vector Creations Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/matrix"; +import classNames from "classnames"; +import { Enable, Resizable } from "re-resizable"; +import { Direction } from "re-resizable/lib/resizer"; +import * as React from "react"; +import { ComponentType, createRef, ReactComponentElement, ReactNode } from "react"; +import { polyfillTouchEvent } from "matrix-react-sdk/src/@types/polyfill"; +import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts"; +import { RovingAccessibleButton, RovingTabIndexWrapper } from "matrix-react-sdk/src/accessibility/RovingTabIndex"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import defaultDispatcher, { MatrixDispatcher } from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import { ListNotificationState } from "matrix-react-sdk/src/stores/notifications/ListNotificationState"; +import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore"; +import { ListAlgorithm, SortAlgorithm } from "matrix-react-sdk/src/stores/room-list/algorithms/models"; +import { ListLayout } from "matrix-react-sdk/src/stores/room-list/ListLayout"; +import { DefaultTagID, TagID } from "matrix-react-sdk/src/stores/room-list/models"; +import RoomListLayoutStore from "matrix-react-sdk/src/stores/room-list/RoomListLayoutStore"; +import RoomListStore, { + LISTS_UPDATE_EVENT, + LISTS_LOADING_EVENT, +} from "matrix-react-sdk/src/stores/room-list/RoomListStore"; +import { arrayFastClone, arrayHasOrderChange } from "matrix-react-sdk/src/utils/arrays"; +import { objectExcluding, objectHasDiff } from "matrix-react-sdk/src/utils/objects"; +import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier"; +import ContextMenu, { + ChevronFace, + ContextMenuTooltipButton, + StyledMenuItemCheckbox, + StyledMenuItemRadio, +} from "matrix-react-sdk/src/components/structures/ContextMenu"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton"; +import ExtraTile from "matrix-react-sdk/src/components/views/rooms/ExtraTile"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { SlidingSyncManager } from "matrix-react-sdk/src/SlidingSyncManager"; +import NotificationBadge from "matrix-react-sdk/src/components/views/rooms/NotificationBadge"; + +import RoomTile from "./RoomTile"; +import { CustomTagID, SuperheroTagID } from "../../../stores/room-list/custom-models"; +import { isVerifiedRoom } from "../../../hooks/useVerifiedRoom"; + +const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS +const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS +export const HEADER_HEIGHT = 32; // As defined by CSS + +const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT; + +// HACK: We really shouldn't have to do this. +polyfillTouchEvent(); + +export interface IAuxButtonProps { + tabIndex: number; + dispatcher?: MatrixDispatcher; +} + +interface IProps { + forRooms: boolean; + startAsHidden: boolean; + label: string; + AuxButtonComponent?: ComponentType; + isMinimized: boolean; + tagId: TagID; + showSkeleton?: boolean; + alwaysVisible?: boolean; + forceExpanded?: boolean; + resizeNotifier: ResizeNotifier; + extraTiles?: ReactComponentElement[] | null; + onListCollapse?: (isExpanded: boolean) => void; +} + +function getLabelId(tagId: TagID): string { + return `mx_RoomSublist_label_${tagId}`; +} + +// TODO: Use re-resizer's NumberSize when it is exposed as the type +interface ResizeDelta { + width: number; + height: number; +} + +type PartialDOMRect = Pick; + +interface IState { + contextMenuPosition?: PartialDOMRect; + isResizing: boolean; + isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered + height: number; + rooms: Room[]; + roomsLoading: boolean; +} + +export default class RoomSublist extends React.Component { + private headerButton = createRef(); + private sublistRef = createRef(); + private tilesRef = createRef(); + private dispatcherRef?: string; + private layout: ListLayout; + private heightAtStart: number; + private notificationState: ListNotificationState; + + private slidingSyncMode: boolean; + + public constructor(props: IProps) { + super(props); + // when this setting is toggled it restarts the app so it's safe to not watch this. + this.slidingSyncMode = SettingsStore.getValue("feature_sliding_sync"); + + this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId); + this.heightAtStart = 0; + this.notificationState = RoomNotificationStateStore.instance.getListState(this.getTagId()); + this.state = { + isResizing: false, + isExpanded: !this.layout.isCollapsed, + height: 0, // to be fixed in a moment, we need `rooms` to calculate this. + rooms: this.getFilteredRooms(this.props.tagId), + roomsLoading: false, + }; + // Why Object.assign() and not this.state.height? Because TypeScript says no. + this.state = Object.assign(this.state, { height: this.calculateInitialHeight() }); + } + + private getTagId(): TagID { + return this.props.tagId === SuperheroTagID.CommunityRooms ? DefaultTagID.Untagged : this.props.tagId; + } + + private getFilteredRooms(tagId: CustomTagID): Room[] { + const listRooms = arrayFastClone(RoomListStore.instance.orderedLists[this.getTagId()] || []); + + return listRooms?.filter((room) => { + const verifiedRoom = isVerifiedRoom(room.name); + const isCommunityRoom = verifiedRoom.isCommunityRoom || verifiedRoom.isTokenGatedRoom; + + if (tagId === SuperheroTagID.CommunityRooms) { + return isCommunityRoom; + } + return !isCommunityRoom; + }); + } + + private calculateInitialHeight(): number { + const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles); + const tileCount = Math.min(this.numTiles, requestedVisibleTiles); + return this.layout.tilesToPixelsWithPadding(tileCount, this.padding); + } + + private get padding(): number { + let padding = RESIZE_HANDLE_HEIGHT; + // this is used for calculating the max height of the whole container, + // and takes into account whether there should be room reserved for the show more/less button + // when fully expanded. We can't rely purely on the layout's defaultVisible tile count + // because there are conditions in which we need to know that the 'show more' button + // is present while well under the default tile limit. + const needsShowMore = this.numTiles > this.numVisibleTiles; + + // ...but also check this or we'll miss if the section is expanded and we need a + // 'show less' + const needsShowLess = this.numTiles > this.layout.defaultVisibleTiles; + + if (needsShowMore || needsShowLess) { + padding += SHOW_N_BUTTON_HEIGHT; + } + return padding; + } + + private get extraTiles(): ReactComponentElement[] | null { + return this.props.extraTiles ?? null; + } + + private get numTiles(): number { + return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles); + } + + private static calcNumTiles(rooms: Room[], extraTiles?: any[] | null): number { + return (rooms || []).length + (extraTiles || []).length; + } + + private get numVisibleTiles(): number { + if (this.slidingSyncMode) { + return this.state.rooms.length; + } + const nVisible = Math.ceil(this.layout.visibleTiles); + return Math.min(nVisible, this.numTiles); + } + + public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { + const prevExtraTiles = prevProps.extraTiles; + // as the rooms can come in one by one we need to reevaluate + // the amount of available rooms to cap the amount of requested visible rooms by the layout + if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) { + this.setState({ height: this.calculateInitialHeight() }); + } + } + + public shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean { + if (objectHasDiff(this.props, nextProps)) { + // Something we don't care to optimize has updated, so update. + return true; + } + + // Do the same check used on props for state, without the rooms we're going to no-op + const prevStateNoRooms = objectExcluding(this.state, ["rooms"]); + const nextStateNoRooms = objectExcluding(nextState, ["rooms"]); + if (objectHasDiff(prevStateNoRooms, nextStateNoRooms)) { + return true; + } + + // If we're supposed to handle extra tiles, take the performance hit and re-render all the + // time so we don't have to consider them as part of the visible room optimization. + const prevExtraTiles = this.props.extraTiles || []; + const nextExtraTiles = nextProps.extraTiles || []; + if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) { + return true; + } + + // If we're about to update the height of the list, we don't really care about which rooms + // are visible or not for no-op purposes, so ensure that the height calculation runs through. + if (RoomSublist.calcNumTiles(nextState.rooms, nextExtraTiles) !== this.numTiles) { + return true; + } + + // Before we go analyzing the rooms, we can see if we're collapsed. If we're collapsed, we don't need + // to render anything. We do this after the height check though to ensure that the height gets appropriately + // calculated for when/if we become uncollapsed. + if (!nextState.isExpanded) { + return false; + } + + // Quickly double check we're not about to break something due to the number of rooms changing. + if (this.state.rooms.length !== nextState.rooms.length) { + return true; + } + + // Finally, determine if the room update (as presumably that's all that's left) is within + // our visible range. If it is, then do a render. If the update is outside our visible range + // then we can skip the update. + // + // We also optimize for order changing here: if the update did happen in our visible range + // but doesn't result in the list re-sorting itself then there's no reason for us to update + // on our own. + const prevSlicedRooms = this.state.rooms.slice(0, this.numVisibleTiles); + const nextSlicedRooms = nextState.rooms.slice(0, this.numVisibleTiles); + if (arrayHasOrderChange(prevSlicedRooms, nextSlicedRooms)) { + return true; + } + + // Finally, nothing happened so no-op the update + return false; + } + + public componentDidMount(): void { + this.dispatcherRef = defaultDispatcher.register(this.onAction); + RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated); + RoomListStore.instance.on(LISTS_LOADING_EVENT, this.onListsLoading); + + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true }); + } + + public componentWillUnmount(): void { + if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); + RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated); + RoomListStore.instance.off(LISTS_LOADING_EVENT, this.onListsLoading); + this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent); + } + + private onListsLoading = (tagId: TagID, isLoading: boolean): void => { + if (this.props.tagId !== tagId) { + return; + } + this.setState({ + roomsLoading: isLoading, + }); + }; + + private onListsUpdated = (): void => { + const stateUpdates = {} as IState; + const currentRooms = this.state.rooms; + const newRooms = this.getFilteredRooms(this.props.tagId); + if (arrayHasOrderChange(currentRooms, newRooms)) { + stateUpdates.rooms = newRooms; + } + + if (Object.keys(stateUpdates).length > 0) { + this.setState(stateUpdates); + } + }; + + private onAction = (payload: ActionPayload): void => { + if (payload.action === Action.ViewRoom && payload.show_room_tile && this.state.rooms) { + // XXX: we have to do this a tick later because we have incorrect intermediate props during a room change + // where we lose the room we are changing from temporarily and then it comes back in an update right after. + setImmediate(() => { + const roomIndex = this.state.rooms.findIndex((r) => r.roomId === payload.room_id); + + if (!this.state.isExpanded && roomIndex > -1) { + this.toggleCollapsed(); + } + // extend the visible section to include the room if it is entirely invisible + if (roomIndex >= this.numVisibleTiles) { + this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT); + this.forceUpdate(); // because the layout doesn't trigger a re-render + } + }); + } + }; + + private applyHeightChange(newHeight: number): void { + const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding)); + this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles); + } + + private onResize = ( + e: MouseEvent | TouchEvent, + travelDirection: Direction, + refToElement: HTMLElement, + delta: ResizeDelta, + ): void => { + const newHeight = this.heightAtStart + delta.height; + this.applyHeightChange(newHeight); + this.setState({ height: newHeight }); + }; + + private onResizeStart = (): void => { + this.heightAtStart = this.state.height; + this.setState({ isResizing: true }); + }; + + private onResizeStop = ( + e: MouseEvent | TouchEvent, + travelDirection: Direction, + refToElement: HTMLElement, + delta: ResizeDelta, + ): void => { + const newHeight = this.heightAtStart + delta.height; + this.applyHeightChange(newHeight); + this.setState({ isResizing: false, height: newHeight }); + }; + + private onShowAllClick = async (): Promise => { + if (this.slidingSyncMode) { + const count = RoomListStore.instance.getCount(this.props.tagId); + await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, { + ranges: [[0, count]], + }); + } + // read number of visible tiles before we mutate it + const numVisibleTiles = this.numVisibleTiles; + const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding); + this.applyHeightChange(newHeight); + this.setState({ height: newHeight }, () => { + // focus the top-most new room + this.focusRoomTile(numVisibleTiles); + }); + }; + + private onShowLessClick = (): void => { + const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding); + this.applyHeightChange(newHeight); + this.setState({ height: newHeight }); + }; + + private focusRoomTile = (index: number): void => { + if (!this.sublistRef.current) return; + const elements = this.sublistRef.current.querySelectorAll(".mx_RoomTile"); + const element = elements && elements[index]; + if (element) { + element.focus(); + } + }; + + private onOpenMenuClick = (ev: ButtonEvent): void => { + ev.preventDefault(); + ev.stopPropagation(); + const target = ev.target as HTMLButtonElement; + this.setState({ contextMenuPosition: target.getBoundingClientRect() }); + }; + + private onContextMenu = (ev: React.MouseEvent): void => { + ev.preventDefault(); + ev.stopPropagation(); + this.setState({ + contextMenuPosition: { + left: ev.clientX, + top: ev.clientY, + height: 0, + }, + }); + }; + + private onCloseMenu = (): void => { + this.setState({ contextMenuPosition: undefined }); + }; + + private onUnreadFirstChanged = (): void => { + const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; + const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance; + RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm); + this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change + }; + + private onTagSortChanged = async (sort: SortAlgorithm): Promise => { + RoomListStore.instance.setTagSorting(this.getTagId(), sort); + this.forceUpdate(); + }; + + private onMessagePreviewChanged = (): void => { + this.layout.showPreviews = !this.layout.showPreviews; + this.forceUpdate(); // because the layout doesn't trigger a re-render + }; + + private onBadgeClick = (ev: React.MouseEvent): void => { + ev.preventDefault(); + ev.stopPropagation(); + + let room; + if (this.props.tagId === DefaultTagID.Invite) { + // switch to first room as that'll be the top of the list for the user + room = this.state.rooms && this.state.rooms[0]; + } else { + // find the first room with a count of the same colour as the badge count + room = RoomListStore.instance.orderedLists[this.getTagId()].find((r: Room) => { + const notifState = this.notificationState.getForRoom(r); + return notifState.count > 0 && notifState.color === this.notificationState.color; + }); + } + + 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: "WebRoomListNotificationBadge", + metricsViaKeyboard: ev.type !== "click", + }); + } + }; + + private onHeaderClick = (): void => { + const possibleSticky = this.headerButton.current?.parentElement; + const sublist = possibleSticky?.parentElement?.parentElement; + const list = sublist?.parentElement?.parentElement; + if (!possibleSticky || !list) return; + + // the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky + const listScrollTop = Math.round(list.scrollTop); + const isAtTop = listScrollTop <= Math.round(HEADER_HEIGHT); + const isAtBottom = listScrollTop >= Math.round(list.scrollHeight - list.offsetHeight); + const isStickyTop = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyTop"); + const isStickyBottom = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyBottom"); + + if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) { + // is sticky - jump to list + sublist.scrollIntoView({ behavior: "smooth" }); + } else { + // on screen - toggle collapse + const isExpanded = this.state.isExpanded; + this.toggleCollapsed(); + // if the bottom list is collapsed then scroll it in so it doesn't expand off screen + if (!isExpanded && isStickyBottom) { + setImmediate(() => { + sublist.scrollIntoView({ behavior: "smooth" }); + }); + } + } + }; + + private toggleCollapsed = (): void => { + if (this.props.forceExpanded) return; + this.layout.isCollapsed = this.state.isExpanded; + this.setState({ isExpanded: !this.layout.isCollapsed }); + if (this.props.onListCollapse) { + this.props.onListCollapse(!this.layout.isCollapsed); + } + }; + + private onHeaderKeyDown = (ev: React.KeyboardEvent): void => { + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case KeyBindingAction.CollapseRoomListSection: + ev.stopPropagation(); + if (this.state.isExpanded) { + // Collapse the room sublist if it isn't already + this.toggleCollapsed(); + } + break; + case KeyBindingAction.ExpandRoomListSection: { + ev.stopPropagation(); + if (!this.state.isExpanded) { + // Expand the room sublist if it isn't already + this.toggleCollapsed(); + } else if (this.sublistRef.current) { + // otherwise focus the first room + const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement; + if (element) { + element.focus(); + } + } + break; + } + } + }; + + private onKeyDown = (ev: React.KeyboardEvent): void => { + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + // On ArrowLeft go to the sublist header + case KeyBindingAction.ArrowLeft: + ev.stopPropagation(); + this.headerButton.current?.focus(); + break; + // Consume ArrowRight so it doesn't cause focus to get sent to composer + case KeyBindingAction.ArrowRight: + ev.stopPropagation(); + } + }; + + private renderVisibleTiles(): React.ReactElement[] { + if (!this.state.isExpanded && !this.props.forceExpanded) { + // don't waste time on rendering + return []; + } + + const tiles: React.ReactElement[] = []; + + if (this.state.rooms) { + let visibleRooms = this.state.rooms; + if (!this.props.forceExpanded) { + visibleRooms = visibleRooms.slice(0, this.numVisibleTiles); + } + + for (const room of visibleRooms) { + tiles.push( + , + ); + } + } + + if (this.extraTiles) { + // HACK: We break typing here, but this 'extra tiles' property shouldn't exist. + (tiles as any[]).push(...this.extraTiles); + } + + // We only have to do this because of the extra tiles. We do it conditionally + // to avoid spending cycles on slicing. It's generally fine to do this though + // as users are unlikely to have more than a handful of tiles when the extra + // tiles are used. + if (tiles.length > this.numVisibleTiles && !this.props.forceExpanded) { + return tiles.slice(0, this.numVisibleTiles); + } + + return tiles; + } + + private renderMenu(): ReactNode { + if (this.props.tagId === DefaultTagID.Suggested) return null; // not sortable + + let contextMenu: JSX.Element | undefined; + if (this.state.contextMenuPosition) { + let isAlphabetical = RoomListStore.instance.getTagSorting(this.getTagId()) === SortAlgorithm.Alphabetic; + let isUnreadFirst = RoomListStore.instance.getListOrder(this.getTagId()) === ListAlgorithm.Importance; + if (this.slidingSyncMode) { + const slidingList = SlidingSyncManager.instance.slidingSync?.getListParams(this.props.tagId); + isAlphabetical = (slidingList?.sort || [])[0] === "by_name"; + isUnreadFirst = (slidingList?.sort || [])[0] === "by_notification_level"; + } + + // Invites don't get some nonsense options, so only add them if we have to. + let otherSections: JSX.Element | undefined; + if (this.props.tagId !== DefaultTagID.Invite) { + otherSections = ( + +
+
+ {_t("common|appearance")} + + {_t("room_list|sort_unread_first")} + + + {_t("room_list|show_previews")} + +
+
+ ); + } + + contextMenu = ( + +
+
+ {_t("room_list|sort_by")} + this.onTagSortChanged(SortAlgorithm.Recent)} + checked={!isAlphabetical} + name={`mx_${this.props.tagId}_sortBy`} + > + {_t("room_list|sort_by_activity")} + + this.onTagSortChanged(SortAlgorithm.Alphabetic)} + checked={isAlphabetical} + name={`mx_${this.props.tagId}_sortBy`} + > + {_t("room_list|sort_by_alphabet")} + +
+ {otherSections} +
+
+ ); + } + + return ( + + + {contextMenu} + + ); + } + + private renderHeader(): React.ReactElement { + return ( + + {({ onFocus, isActive, ref }) => { + const tabIndex = isActive ? 0 : -1; + + let ariaLabel = _t("a11y_jump_first_unread_room"); + if (this.props.tagId === DefaultTagID.Invite) { + ariaLabel = _t("a11y|jump_first_invite"); + } + + const badge = ( + + ); + + let addRoomButton: JSX.Element | undefined; + if (this.props.AuxButtonComponent) { + const AuxButtonComponent = this.props.AuxButtonComponent; + addRoomButton = ; + } + + const collapseClasses = classNames({ + mx_RoomSublist_collapseBtn: true, + mx_RoomSublist_collapseBtn_collapsed: !this.state.isExpanded && !this.props.forceExpanded, + }); + + const classes = classNames({ + mx_RoomSublist_headerContainer: true, + mx_RoomSublist_headerContainer_withAux: !!addRoomButton, + }); + + const badgeContainer =
{badge}
; + + let Button: React.ComponentType> = AccessibleButton; + if (this.props.isMinimized) { + Button = AccessibleTooltipButton; + } + + // Note: the addRoomButton conditionally gets moved around + // the DOM depending on whether or not the list is minimized. + // If we're minimized, we want it below the header so it + // doesn't become sticky. + // The same applies to the notification badge. + return ( +
+
+
+ + {this.renderMenu()} + {this.props.isMinimized ? null : badgeContainer} + {this.props.isMinimized ? null : addRoomButton} +
+
+ {this.props.isMinimized ? badgeContainer : null} + {this.props.isMinimized ? addRoomButton : null} +
+ ); + }} +
+ ); + } + + private onScrollPrevent(e: Event): void { + // the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable + // this fixes https://github.com/vector-im/element-web/issues/14413 + (e.target as HTMLDivElement).scrollTop = 0; + } + + public render(): React.ReactElement { + const visibleTiles = this.renderVisibleTiles(); + const hidden = !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true; + const classes = classNames({ + mx_RoomSublist: true, + mx_RoomSublist_hasMenuOpen: !!this.state.contextMenuPosition, + mx_RoomSublist_minimized: this.props.isMinimized, + mx_RoomSublist_hidden: hidden, + }); + + let content: JSX.Element | undefined; + if (this.state.roomsLoading) { + content =
; + } else if (visibleTiles.length > 0 && this.props.forceExpanded) { + content = ( +
+
+ {visibleTiles} +
+
+ ); + } else if (visibleTiles.length > 0) { + const layout = this.layout; // to shorten calls + + const minTiles = Math.min(layout.minVisibleTiles, this.numTiles); + const showMoreAtMinHeight = minTiles < this.numTiles; + const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0); + const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding); + const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding); + const showMoreBtnClasses = classNames({ + mx_RoomSublist_showNButton: true, + }); + + // If we're hiding rooms, show a 'show more' button to the user. This button + // floats above the resize handle, if we have one present. If the user has all + // tiles visible, it becomes 'show less'. + let showNButton: JSX.Element | undefined; + const hasMoreSlidingSync = + this.slidingSyncMode && RoomListStore.instance.getCount(this.getTagId()) > this.state.rooms.length; + if (maxTilesPx > this.state.height || hasMoreSlidingSync) { + // the height of all the tiles is greater than the section height: we need a 'show more' button + const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT; + const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight); + let numMissing = this.numTiles - amountFullyShown; + if (this.slidingSyncMode) { + numMissing = RoomListStore.instance.getCount(this.getTagId()) - amountFullyShown; + } + const label = _t("room_list|show_n_more", { count: numMissing }); + let showMoreText: ReactNode = {label}; + if (this.props.isMinimized) showMoreText = null; + showNButton = ( + + + {/* set by CSS masking */} + + {showMoreText} + + ); + } else if (this.numTiles > this.layout.defaultVisibleTiles) { + // we have all tiles visible - add a button to show less + const label = _t("room_list|show_less"); + let showLessText: ReactNode = {label}; + if (this.props.isMinimized) showLessText = null; + showNButton = ( + + + {/* set by CSS masking */} + + {showLessText} + + ); + } + + // Figure out if we need a handle + const handles: Enable = { + bottom: true, // the only one we need, but the others must be explicitly false + bottomLeft: false, + bottomRight: false, + left: false, + right: false, + top: false, + topLeft: false, + topRight: false, + }; + if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) { + // we're at a minimum, don't have a bottom handle + handles.bottom = false; + } + + // We have to account for padding so we can accommodate a 'show more' button and + // the resize handle, which are pinned to the bottom of the container. This is the + // easiest way to have a resize handle below the button as otherwise we're writing + // our own resize handling and that doesn't sound fun. + // + // The layout class has some helpers for dealing with padding, as we don't want to + // apply it in all cases. If we apply it in all cases, the resizing feels like it + // goes backwards and can become wildly incorrect (visibleTiles says 18 when there's + // only mathematically 7 possible). + + const handleWrapperClasses = classNames({ + mx_RoomSublist_resizerHandles: true, + mx_RoomSublist_resizerHandles_showNButton: !!showNButton, + }); + + content = ( + + +
+ {visibleTiles} +
+ {showNButton} +
+
+ ); + } else if (this.props.showSkeleton && this.state.isExpanded) { + content =
; + } + + return ( +
+ {this.renderHeader()} + {content} +
+ ); + } +} diff --git a/src/stores/room-list/custom-models.ts b/src/stores/room-list/custom-models.ts new file mode 100644 index 0000000000..89d96ed1ba --- /dev/null +++ b/src/stores/room-list/custom-models.ts @@ -0,0 +1,7 @@ +import { DefaultTagID } from "matrix-react-sdk/src/stores/room-list/models"; + +export enum SuperheroTagID { + CommunityRooms = "CommunityRooms", +} + +export type CustomTagID = string | DefaultTagID | SuperheroTagID;