diff --git a/components.json b/components.json index d6796bc3c65..947fe648f41 100644 --- a/components.json +++ b/components.json @@ -17,5 +17,6 @@ "src/components/views/right_panel/UserInfo.tsx": "src/components/views/right_panel/UserInfo.tsx", "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/views/elements/Pill.tsx": "src/components/views/elements/Pill.tsx", + "src/components/structures/LeftPanel.tsx": "src/components/structures/LeftPanel.tsx" } diff --git a/res/themes/superhero/img/logos/superhero.svg b/res/themes/superhero/img/logos/superhero.svg new file mode 100644 index 00000000000..186befc10ed --- /dev/null +++ b/res/themes/superhero/img/logos/superhero.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx new file mode 100644 index 00000000000..f7f6eea7168 --- /dev/null +++ b/src/components/structures/LeftPanel.tsx @@ -0,0 +1,435 @@ +/* +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 * as React from "react"; +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"; +import RoomSearch from "matrix-react-sdk/src/components/structures/RoomSearch"; +import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier"; +import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton"; +import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore"; +import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "matrix-react-sdk/src/stores/spaces"; +import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager"; +import UIStore from "matrix-react-sdk/src/stores/UIStore"; +import { IState as IRovingTabIndexState } from "matrix-react-sdk/src/accessibility/RovingTabIndex"; +import RoomListHeader from "matrix-react-sdk/src/components/views/rooms/RoomListHeader"; +import { BreadcrumbsStore } from "matrix-react-sdk/src/stores/BreadcrumbsStore"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "matrix-react-sdk/src/stores/room-list/RoomListStore"; +import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore"; +import IndicatorScrollbar from "matrix-react-sdk/src/components/structures/IndicatorScrollbar"; +import RoomBreadcrumbs from "matrix-react-sdk/src/components/views/rooms/RoomBreadcrumbs"; +import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts"; +import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents"; +import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature"; +import { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +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 { Icon as Superhero } from "../../../res/themes/superhero/img/logos/superhero.svg"; + +interface IProps { + isMinimized: boolean; + pageType: PageType; + resizeNotifier: ResizeNotifier; +} + +enum BreadcrumbsMode { + Disabled, + Legacy, +} + +interface IState { + showBreadcrumbs: BreadcrumbsMode; + activeSpace: SpaceKey; +} + +export default class LeftPanel extends React.Component { + private listContainerRef = createRef(); + private roomListRef = createRef(); + private focusedElement: Element | null = null; + private isDoingStickyHeaders = false; + + public constructor(props: IProps) { + super(props); + + this.state = { + activeSpace: SpaceStore.instance.activeSpace, + showBreadcrumbs: LeftPanel.breadcrumbsMode, + }; + + BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); + RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + } + + private static get breadcrumbsMode(): BreadcrumbsMode { + return !BreadcrumbsStore.instance.visible ? BreadcrumbsMode.Disabled : BreadcrumbsMode.Legacy; + } + + public componentDidMount(): void { + if (this.listContainerRef.current) { + UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); + // 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.listContainerRef.current.addEventListener("scroll", this.onScroll, { passive: true }); + } + UIStore.instance.on("ListContainer", this.refreshStickyHeaders); + } + + public componentWillUnmount(): void { + BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); + RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + UIStore.instance.stopTrackingElementDimensions("ListContainer"); + UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); + this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); + } + + public componentDidUpdate(prevProps: IProps, prevState: IState): void { + if (prevState.activeSpace !== this.state.activeSpace) { + this.refreshStickyHeaders(); + } + } + + private updateActiveSpace = (activeSpace: SpaceKey): void => { + this.setState({ activeSpace }); + }; + + private onDialPad = (): void => { + dis.fire(Action.OpenDialPad); + }; + + private onExplore = (ev: ButtonEvent): void => { + dis.fire(Action.ViewRoomDirectory); + PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev); + }; + + private refreshStickyHeaders = (): void => { + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); + }; + + private onBreadcrumbsUpdate = (): void => { + const newVal = LeftPanel.breadcrumbsMode; + if (newVal !== this.state.showBreadcrumbs) { + this.setState({ showBreadcrumbs: newVal }); + + // Update the sticky headers too as the breadcrumbs will be popping in or out. + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); + } + }; + + private handleStickyHeaders(list: HTMLDivElement): void { + if (this.isDoingStickyHeaders) return; + this.isDoingStickyHeaders = true; + window.requestAnimationFrame(() => { + this.doStickyHeaders(list); + this.isDoingStickyHeaders = false; + }); + } + + private doStickyHeaders(list: HTMLDivElement): void { + if (!list.parentElement) return; + const topEdge = list.scrollTop; + const bottomEdge = list.offsetHeight + list.scrollTop; + const sublists = list.querySelectorAll(".mx_RoomSublist:not(.mx_RoomSublist_hidden)"); + + // We track which styles we want on a target before making the changes to avoid + // excessive layout updates. + const targetStyles = new Map< + HTMLDivElement, + { + stickyTop?: boolean; + stickyBottom?: boolean; + makeInvisible?: boolean; + } + >(); + + let lastTopHeader: HTMLDivElement | undefined; + let firstBottomHeader: HTMLDivElement | undefined; + for (const sublist of sublists) { + const header = sublist.querySelector(".mx_RoomSublist_stickable"); + if (!header) continue; // this should never occur + header.style.removeProperty("display"); // always clear display:none first + + // When an element is <=40% off screen, make it take over + const offScreenFactor = 0.4; + const isOffTop = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT <= topEdge; + const isOffBottom = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT >= bottomEdge; + + if (isOffTop || sublist === sublists[0]) { + targetStyles.set(header, { stickyTop: true }); + if (lastTopHeader) { + lastTopHeader.style.display = "none"; + targetStyles.set(lastTopHeader, { makeInvisible: true }); + } + lastTopHeader = header; + } else if (isOffBottom && !firstBottomHeader) { + targetStyles.set(header, { stickyBottom: true }); + firstBottomHeader = header; + } else { + targetStyles.set(header, {}); // nothing == clear + } + } + + // Run over the style changes and make them reality. We check to see if we're about to + // cause a no-op update, as adding/removing properties that are/aren't there cause + // layout updates. + for (const header of targetStyles.keys()) { + const style = targetStyles.get(header)!; + + if (style.makeInvisible) { + // we will have already removed the 'display: none', so add it back. + header.style.display = "none"; + continue; // nothing else to do, even if sticky somehow + } + + if (style.stickyTop) { + if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) { + header.classList.add("mx_RoomSublist_headerContainer_stickyTop"); + } + + const newTop = `${list.parentElement.offsetTop}px`; + if (header.style.top !== newTop) { + header.style.top = newTop; + } + } else { + if (header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) { + header.classList.remove("mx_RoomSublist_headerContainer_stickyTop"); + } + if (header.style.top) { + header.style.removeProperty("top"); + } + } + + if (style.stickyBottom) { + if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { + header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); + } + + const offset = + UIStore.instance.windowHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); + const newBottom = `${offset}px`; + if (header.style.bottom !== newBottom) { + header.style.bottom = newBottom; + } + } else { + if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { + header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom"); + } + if (header.style.bottom) { + header.style.removeProperty("bottom"); + } + } + + if (style.stickyTop || style.stickyBottom) { + if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { + header.classList.add("mx_RoomSublist_headerContainer_sticky"); + } + + const listDimensions = UIStore.instance.getElementDimensions("ListContainer"); + if (listDimensions) { + const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles + const headerStickyWidth = listDimensions.width - headerRightMargin; + const newWidth = `${headerStickyWidth}px`; + if (header.style.width !== newWidth) { + header.style.width = newWidth; + } + } + } else if (!style.stickyTop && !style.stickyBottom) { + if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { + header.classList.remove("mx_RoomSublist_headerContainer_sticky"); + } + + if (header.style.width) { + header.style.removeProperty("width"); + } + } + } + + // add appropriate sticky classes to wrapper so it has + // the necessary top/bottom padding to put the sticky header in + const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper + if (!listWrapper) return; + if (lastTopHeader) { + listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop"); + } else { + listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop"); + } + if (firstBottomHeader) { + listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom"); + } else { + listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom"); + } + } + + private onScroll = (ev: Event): void => { + const list = ev.target as HTMLDivElement; + this.handleStickyHeaders(list); + }; + + private onFocus = (ev: React.FocusEvent): void => { + this.focusedElement = ev.target; + }; + + private onBlur = (): void => { + this.focusedElement = null; + }; + + private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState): void => { + if (!this.focusedElement) return; + + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case KeyBindingAction.NextRoom: + if (!state) { + ev.stopPropagation(); + ev.preventDefault(); + this.roomListRef.current?.focus(); + } + break; + } + }; + + private renderBreadcrumbs(): React.ReactNode { + if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) { + return ( + + + + ); + } + } + + private renderSearchDialExplore(): React.ReactNode { + let dialPadButton: JSX.Element | undefined; + + // If we have dialer support, show a button to bring up the dial pad + // to start a new call + if (LegacyCallHandler.instance.getSupportsPstnProtocol()) { + dialPadButton = ( + + ); + } + + let rightButton: JSX.Element | undefined; + if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) { + rightButton = ( + + ); + } + + return ( + + + + {dialPadButton} + {rightButton} + + ); + } + + public render(): React.ReactNode { + const roomList = ( + + ); + + const containerClasses = classNames({ + mx_LeftPanel: true, + mx_LeftPanel_minimized: this.props.isMinimized, + }); + + const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar"); + + return ( + + + + + + + {shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()} + {this.renderBreadcrumbs()} + {!this.props.isMinimized && } + + + + {roomList} + + + + + ); + } +}