From 4a6d46b76adc5384d2a9431ac54a77322d0fee00 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 1 Dec 2021 10:50:06 +0000 Subject: [PATCH] Replace breadcrumbs with recently viewed menu (#7073) --- res/css/_components.scss | 2 + res/css/structures/_ContextualMenu.scss | 1 + res/css/structures/_LeftPanel.scss | 22 +- res/css/views/avatars/_BaseAvatar.scss | 8 +- .../views/elements/_InteractiveTooltip.scss | 97 ++++ .../views/rooms/_RecentlyViewedButton.scss | 73 +++ res/img/element-icons/clock.svg | 4 + src/components/structures/LeftPanel.tsx | 49 +- .../views/elements/InteractiveTooltip.tsx | 496 ++++++++++++++++++ .../views/rooms/RecentlyViewedButton.tsx | 73 +++ .../tabs/user/PreferencesUserSettingsTab.tsx | 10 +- src/i18n/strings/en_EN.json | 2 + src/settings/Settings.tsx | 8 + src/stores/BreadcrumbsStore.ts | 19 +- .../views/elements/InteractiveTooltip-test.ts | 162 ++++++ 15 files changed, 992 insertions(+), 34 deletions(-) create mode 100644 res/css/views/elements/_InteractiveTooltip.scss create mode 100644 res/css/views/rooms/_RecentlyViewedButton.scss create mode 100644 res/img/element-icons/clock.svg create mode 100644 src/components/views/elements/InteractiveTooltip.tsx create mode 100644 src/components/views/rooms/RecentlyViewedButton.tsx create mode 100644 test/components/views/elements/InteractiveTooltip-test.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 30b0f9d799..adfd98925a 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -143,6 +143,7 @@ @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; +@import "./views/elements/_InteractiveTooltip.scss"; @import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; @@ -230,6 +231,7 @@ @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; +@import "./views/rooms/_RecentlyViewedButton.scss"; @import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 67e4b41268..18873978d3 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -37,6 +37,7 @@ limitations under the License. position: absolute; font-size: $font-14px; z-index: 5001; + width: max-content; } .mx_ContextualMenu_right { diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 5214c0260d..f8ca34a0c7 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -133,7 +133,8 @@ $roomListCollapsedWidth: 68px; display: none; } - & + .mx_LeftPanel_exploreButton { + & + .mx_LeftPanel_exploreButton, + & + .mx_LeftPanel_recentsButton { // Cheaty way to return the occupied space to the filter input flex-basis: 0; margin: 0; @@ -166,11 +167,12 @@ $roomListCollapsedWidth: 68px; mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $secondary-content; + background-color: $secondary-content; } } - .mx_LeftPanel_exploreButton { + .mx_LeftPanel_exploreButton, + .mx_LeftPanel_recentsButton { width: 32px; height: 32px; border-radius: 8px; @@ -185,11 +187,10 @@ $roomListCollapsedWidth: 68px; left: 8px; width: 16px; height: 16px; - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $secondary-content; + background-color: $secondary-content; } &:hover { @@ -200,6 +201,14 @@ $roomListCollapsedWidth: 68px; } } } + + .mx_LeftPanel_exploreButton::before { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + + .mx_LeftPanel_recentsButton::before { + mask-image: url('$(res)/img/element-icons/clock.svg'); + } } .mx_LeftPanel_roomListFilterCount { @@ -257,7 +266,8 @@ $roomListCollapsedWidth: 68px; background-color: transparent; } - .mx_LeftPanel_exploreButton { + .mx_LeftPanel_exploreButton, + .mx_LeftPanel_recentsButton { margin-left: 0; margin-top: 8px; } diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 486958887e..964e815626 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -27,11 +27,17 @@ limitations under the License. // https://bugzilla.mozilla.org/show_bug.cgi?id=255139 display: inline-block; user-select: none; + + &.mx_RoomAvatar_isSpaceRoom { + &.mx_BaseAvatar_image, .mx_BaseAvatar_image { + border-radius: 8px; + } + } } .mx_BaseAvatar_initial { position: absolute; - left: 0px; + left: 0; color: $avatar-initial-color; text-align: center; speak: none; diff --git a/res/css/views/elements/_InteractiveTooltip.scss b/res/css/views/elements/_InteractiveTooltip.scss new file mode 100644 index 0000000000..8196441b6d --- /dev/null +++ b/res/css/views/elements/_InteractiveTooltip.scss @@ -0,0 +1,97 @@ +/* +Copyright 2019 - 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. +*/ + +.mx_InteractiveTooltip_wrapper { + position: fixed; + z-index: 5000; +} + +.mx_InteractiveTooltip { + border-radius: 8px; + background-color: $background; + color: $primary-content; + position: absolute; + z-index: 5001; + box-shadow: 0 24px 8px rgb(17 17 26 / 4%), 0 8px 32px rgb(17 17 26 / 4%); +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_top { + top: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_left { + left: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_right { + right: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_bottom { + bottom: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip_chevron_top { + position: absolute; + left: calc(50% - 8px); + top: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-bottom: 8px solid $background; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_top { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(135deg); + border-radius: 0 0 0 3px; + top: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} + +.mx_InteractiveTooltip_chevron_bottom { + position: absolute; + left: calc(50% - 8px); + bottom: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-top: 8px solid $background; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_bottom { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(-45deg); + border-radius: 0 0 0 3px; + bottom: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} diff --git a/res/css/views/rooms/_RecentlyViewedButton.scss b/res/css/views/rooms/_RecentlyViewedButton.scss new file mode 100644 index 0000000000..ba6c6e04f1 --- /dev/null +++ b/res/css/views/rooms/_RecentlyViewedButton.scss @@ -0,0 +1,73 @@ +/* +Copyright 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. +*/ + +.mx_RecentlyViewedButton_ContextMenu { + padding: 16px 8px 16px 16px; + width: max-content; + max-width: 240px; + max-height: 400px; + border: 1px solid rgba($primary-content, .1); + border-radius: 8px; + box-shadow: 0 8px 4px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + + > h4 { + margin: 0 0 12px 0; + } + + > div { + overflow-y: auto; + + * { + margin-right: 4px; + } + } + + .mx_AccessibleButton { + margin-top: 2px; + padding: 4px; + display: flex; + align-items: center; + border-radius: 8px; + min-height: 34px; + + &:hover { + background-color: $panel-actions; + } + + .mx_BaseAvatar { + margin-right: 8px; + width: 24px; + } + + .mx_RecentlyViewedButton_entry_label { + display: grid; + + > div { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .mx_RecentlyViewedButton_entry_spaces { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + } + } +} diff --git a/res/img/element-icons/clock.svg b/res/img/element-icons/clock.svg new file mode 100644 index 0000000000..2fb0705c39 --- /dev/null +++ b/res/img/element-icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 7c377e168a..36038f6d9b 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -25,12 +25,7 @@ import CallHandler from "../../CallHandler"; import { HEADER_HEIGHT } from "../views/rooms/RoomSublist"; import { Action } from "../../dispatcher/actions"; import RoomSearch from "./RoomSearch"; -import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs"; -import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; -import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; -import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import LeftPanelWidget from "./LeftPanelWidget"; import { replaceableComponent } from "../../utils/replaceableComponent"; @@ -41,14 +36,27 @@ import UIStore from "../../stores/UIStore"; import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex"; import RoomListHeader from "../views/rooms/RoomListHeader"; import { Key } from "../../Keyboard"; +import RecentlyViewedButton from "../views/rooms/RecentlyViewedButton"; +import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import IndicatorScrollbar from "./IndicatorScrollbar"; +import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs"; +import SettingsStore from "../../settings/SettingsStore"; interface IProps { isMinimized: boolean; resizeNotifier: ResizeNotifier; } +enum BreadcrumbsMode { + Disabled, + Legacy, + Labs, +} + interface IState { - showBreadcrumbs: boolean; + showBreadcrumbs: BreadcrumbsMode; activeSpace: SpaceKey; } @@ -65,8 +73,8 @@ export default class LeftPanel extends React.Component { super(props); this.state = { - showBreadcrumbs: BreadcrumbsStore.instance.visible, activeSpace: SpaceStore.instance.activeSpace, + showBreadcrumbs: LeftPanel.breadcrumbsMode, }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -74,6 +82,11 @@ export default class LeftPanel extends React.Component { SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); } + private static get breadcrumbsMode(): BreadcrumbsMode { + if (!SettingsStore.getValue("breadcrumbs")) return BreadcrumbsMode.Disabled; + return SettingsStore.getValue("feature_breadcrumbs_v2") ? BreadcrumbsMode.Labs : BreadcrumbsMode.Legacy; + } + public componentDidMount() { UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current); UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); @@ -116,7 +129,7 @@ export default class LeftPanel extends React.Component { }; private onBreadcrumbsUpdate = () => { - const newVal = BreadcrumbsStore.instance.visible; + const newVal = LeftPanel.breadcrumbsMode; if (newVal !== this.state.showBreadcrumbs) { this.setState({ showBreadcrumbs: newVal }); @@ -323,7 +336,7 @@ export default class LeftPanel extends React.Component { }; private renderBreadcrumbs(): React.ReactNode { - if (this.state.showBreadcrumbs && !this.props.isMinimized) { + if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) { return ( { />; } + let rightButton: JSX.Element; + if (this.state.showBreadcrumbs === BreadcrumbsMode.Labs) { + rightButton = ; + } else if (this.state.activeSpace === MetaSpace.Home) { + rightButton = ; + } + return (
{ /> { dialPadButton } - - { this.state.activeSpace === MetaSpace.Home && } + { rightButton }
); } diff --git a/src/components/views/elements/InteractiveTooltip.tsx b/src/components/views/elements/InteractiveTooltip.tsx new file mode 100644 index 0000000000..c8920abecd --- /dev/null +++ b/src/components/views/elements/InteractiveTooltip.tsx @@ -0,0 +1,496 @@ +/* +Copyright 2019 - 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 React, { CSSProperties, MouseEventHandler, ReactNode, RefCallback } from "react"; +import ReactDOM from "react-dom"; +import classNames from "classnames"; + +import UIStore from "../../../stores/UIStore"; +import { ChevronFace } from "../../structures/ContextMenu"; + +const InteractiveTooltipContainerId = "mx_InteractiveTooltip_Container"; + +// If the distance from tooltip to window edge is below this value, the tooltip +// will flip around to the other side of the target. +const MIN_SAFE_DISTANCE_TO_WINDOW_EDGE = 20; + +function getOrCreateContainer(): HTMLElement { + let container = document.getElementById(InteractiveTooltipContainerId); + + if (!container) { + container = document.createElement("div"); + container.id = InteractiveTooltipContainerId; + document.body.appendChild(container); + } + + return container; +} + +interface IRect { + top: number; + right: number; + bottom: number; + left: number; +} + +function isInRect(x: number, y: number, rect: IRect): boolean { + const { top, right, bottom, left } = rect; + return x >= left && x <= right && y >= top && y <= bottom; +} + +/** + * Returns the positive slope of the diagonal of the rect. + * + * @param {DOMRect} rect + * @return {number} + */ +function getDiagonalSlope(rect: IRect): number { + const { top, right, bottom, left } = rect; + return (bottom - top) / (right - left); +} + +function isInUpperLeftHalf(x: number, y: number, rect: IRect): boolean { + const { bottom, left } = rect; + // Negative slope because Y values grow downwards and for this case, the + // diagonal goes from larger to smaller Y values. + const diagonalSlope = getDiagonalSlope(rect) * -1; + return isInRect(x, y, rect) && (y <= bottom + diagonalSlope * (x - left)); +} + +function isInLowerRightHalf(x: number, y: number, rect: IRect): boolean { + const { bottom, left } = rect; + // Negative slope because Y values grow downwards and for this case, the + // diagonal goes from larger to smaller Y values. + const diagonalSlope = getDiagonalSlope(rect) * -1; + return isInRect(x, y, rect) && (y >= bottom + diagonalSlope * (x - left)); +} + +function isInUpperRightHalf(x: number, y: number, rect: IRect): boolean { + const { top, left } = rect; + // Positive slope because Y values grow downwards and for this case, the + // diagonal goes from smaller to larger Y values. + const diagonalSlope = getDiagonalSlope(rect) * 1; + return isInRect(x, y, rect) && (y <= top + diagonalSlope * (x - left)); +} + +function isInLowerLeftHalf(x: number, y: number, rect: IRect): boolean { + const { top, left } = rect; + // Positive slope because Y values grow downwards and for this case, the + // diagonal goes from smaller to larger Y values. + const diagonalSlope = getDiagonalSlope(rect) * 1; + return isInRect(x, y, rect) && (y >= top + diagonalSlope * (x - left)); +} + +export enum Direction { + Top, + Left, + Bottom, + Right, +} + +// exported for tests +export function mouseWithinRegion( + x: number, + y: number, + direction: Direction, + targetRect: DOMRect, + contentRect: DOMRect, +): boolean { + // When moving the mouse from the target to the tooltip, we create a safe area + // that includes the tooltip, the target, and the trapezoid ABCD between them: + // ┌───────────┐ + // │ │ + // │ │ + // A └───E───F───┘ B + // V + // ┌─┐ + // │ │ + // C└─┘D + // + // As long as the mouse remains inside the safe area, the tooltip will stay open. + const buffer = 50; + if (isInRect(x, y, targetRect)) { + return true; + } + + switch (direction) { + case Direction.Left: { + const contentRectWithBuffer = { + top: contentRect.top - buffer, + right: contentRect.right, + bottom: contentRect.bottom + buffer, + left: contentRect.left - buffer, + }; + const trapezoidTop = { + top: contentRect.top - buffer, + right: targetRect.right, + bottom: targetRect.top, + left: contentRect.right, + }; + const trapezoidCenter = { + top: targetRect.top, + right: targetRect.left, + bottom: targetRect.bottom, + left: contentRect.right, + }; + const trapezoidBottom = { + top: targetRect.bottom, + right: targetRect.right, + bottom: contentRect.bottom + buffer, + left: contentRect.right, + }; + + if ( + isInRect(x, y, contentRectWithBuffer) || + isInLowerLeftHalf(x, y, trapezoidTop) || + isInRect(x, y, trapezoidCenter) || + isInUpperLeftHalf(x, y, trapezoidBottom) + ) { + return true; + } + + break; + } + + case Direction.Right: { + const contentRectWithBuffer = { + top: contentRect.top - buffer, + right: contentRect.right + buffer, + bottom: contentRect.bottom + buffer, + left: contentRect.left, + }; + const trapezoidTop = { + top: contentRect.top - buffer, + right: contentRect.left, + bottom: targetRect.top, + left: targetRect.left, + }; + const trapezoidCenter = { + top: targetRect.top, + right: contentRect.left, + bottom: targetRect.bottom, + left: targetRect.right, + }; + const trapezoidBottom = { + top: targetRect.bottom, + right: contentRect.left, + bottom: contentRect.bottom + buffer, + left: targetRect.left, + }; + + if ( + isInRect(x, y, contentRectWithBuffer) || + isInLowerRightHalf(x, y, trapezoidTop) || + isInRect(x, y, trapezoidCenter) || + isInUpperRightHalf(x, y, trapezoidBottom) + ) { + return true; + } + + break; + } + + case Direction.Top: { + const contentRectWithBuffer = { + top: contentRect.top - buffer, + right: contentRect.right + buffer, + bottom: contentRect.bottom, + left: contentRect.left - buffer, + }; + const trapezoidLeft = { + top: contentRect.bottom, + right: targetRect.left, + bottom: targetRect.bottom, + left: contentRect.left - buffer, + }; + const trapezoidCenter = { + top: contentRect.bottom, + right: targetRect.right, + bottom: targetRect.top, + left: targetRect.left, + }; + const trapezoidRight = { + top: contentRect.bottom, + right: contentRect.right + buffer, + bottom: targetRect.bottom, + left: targetRect.right, + }; + + if ( + isInRect(x, y, contentRectWithBuffer) || + isInUpperRightHalf(x, y, trapezoidLeft) || + isInRect(x, y, trapezoidCenter) || + isInUpperLeftHalf(x, y, trapezoidRight) + ) { + return true; + } + + break; + } + + case Direction.Bottom: { + const contentRectWithBuffer = { + top: contentRect.top, + right: contentRect.right + buffer, + bottom: contentRect.bottom + buffer, + left: contentRect.left - buffer, + }; + const trapezoidLeft = { + top: targetRect.top, + right: targetRect.left, + bottom: contentRect.top, + left: contentRect.left - buffer, + }; + const trapezoidCenter = { + top: targetRect.bottom, + right: targetRect.right, + bottom: contentRect.top, + left: targetRect.left, + }; + const trapezoidRight = { + top: targetRect.top, + right: contentRect.right + buffer, + bottom: contentRect.top, + left: targetRect.right, + }; + + if ( + isInRect(x, y, contentRectWithBuffer) || + isInLowerRightHalf(x, y, trapezoidLeft) || + isInRect(x, y, trapezoidCenter) || + isInLowerLeftHalf(x, y, trapezoidRight) + ) { + return true; + } + + break; + } + } + + return false; +} + +interface IProps { + children(props: { + ref: RefCallback; + onMouseOver: MouseEventHandler; + }): ReactNode; + // Content to show in the tooltip + content: ReactNode; + direction?: Direction; + // Function to call when visibility of the tooltip changes + onVisibilityChange?(visible: boolean): void; +} + +interface IState { + contentRect: DOMRect; + visible: boolean; +} + +/* + * This style of tooltip takes a "target" element as its child and centers the + * tooltip along one edge of the target. + */ +export default class InteractiveTooltip extends React.Component { + private target: HTMLElement; + + public static defaultProps = { + side: Direction.Top, + }; + + constructor(props, context) { + super(props, context); + + this.state = { + contentRect: null, + visible: false, + }; + } + + componentDidUpdate() { + // Whenever this passthrough component updates, also render the tooltip + // in a separate DOM tree. This allows the tooltip content to participate + // the normal React rendering cycle: when this component re-renders, the + // tooltip content re-renders. + // Once we upgrade to React 16, this could be done a bit more naturally + // using the portals feature instead. + this.renderTooltip(); + } + + componentWillUnmount() { + document.removeEventListener("mousemove", this.onMouseMove); + } + + private collectContentRect = (element: HTMLElement): void => { + // We don't need to clean up when unmounting, so ignore + if (!element) return; + + this.setState({ + contentRect: element.getBoundingClientRect(), + }); + }; + + private collectTarget = (element: HTMLElement) => { + this.target = element; + }; + + private onLeftOfTarget(): boolean { + const { contentRect } = this.state; + const targetRect = this.target.getBoundingClientRect(); + + if (this.props.direction === Direction.Left) { + const targetLeft = targetRect.left + window.pageXOffset; + return !contentRect || (targetLeft - contentRect.width > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); + } else { + const targetRight = targetRect.right + window.pageXOffset; + const spaceOnRight = UIStore.instance.windowWidth - targetRight; + return !contentRect || (spaceOnRight - contentRect.width < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); + } + } + + private aboveTarget(): boolean { + const { contentRect } = this.state; + const targetRect = this.target.getBoundingClientRect(); + + if (this.props.direction === Direction.Top) { + const targetTop = targetRect.top + window.pageYOffset; + return !contentRect || (targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); + } else { + const targetBottom = targetRect.bottom + window.pageYOffset; + const spaceBelow = UIStore.instance.windowHeight - targetBottom; + return !contentRect || (spaceBelow - contentRect.height < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); + } + } + + private get isOnTheSide(): boolean { + return this.props.direction === Direction.Left || this.props.direction === Direction.Right; + } + + private onMouseMove = (ev: MouseEvent) => { + const { clientX: x, clientY: y } = ev; + const { contentRect } = this.state; + const targetRect = this.target.getBoundingClientRect(); + + let direction: Direction; + if (this.isOnTheSide) { + direction = this.onLeftOfTarget() ? Direction.Left : Direction.Right; + } else { + direction = this.aboveTarget() ? Direction.Top : Direction.Bottom; + } + + if (!mouseWithinRegion(x, y, direction, targetRect, contentRect)) { + this.hideTooltip(); + } + }; + + private onTargetMouseOver = (): void => { + this.showTooltip(); + }; + + private showTooltip(): void { + // Don't enter visible state if we haven't collected the target yet + if (!this.target) return; + + this.setState({ + visible: true, + }); + this.props.onVisibilityChange?.(true); + document.addEventListener("mousemove", this.onMouseMove); + } + + public hideTooltip() { + this.setState({ + visible: false, + }); + this.props.onVisibilityChange?.(false); + document.removeEventListener("mousemove", this.onMouseMove); + } + + private renderTooltip() { + const { contentRect, visible } = this.state; + if (!visible) { + ReactDOM.unmountComponentAtNode(getOrCreateContainer()); + return null; + } + + const targetRect = this.target.getBoundingClientRect(); + + // The window X and Y offsets are to adjust position when zoomed in to page + const targetLeft = targetRect.left + window.pageXOffset; + const targetRight = targetRect.right + window.pageXOffset; + const targetBottom = targetRect.bottom + window.pageYOffset; + const targetTop = targetRect.top + window.pageYOffset; + + // Place the tooltip above the target by default. If we find that the + // tooltip content would extend past the safe area towards the window + // edge, flip around to below the target. + const position: Partial = {}; + let chevronFace: ChevronFace = null; + if (this.isOnTheSide) { + if (this.onLeftOfTarget()) { + position.left = targetLeft; + chevronFace = ChevronFace.Right; + } else { + position.left = targetRight; + chevronFace = ChevronFace.Left; + } + + position.top = targetTop; + } else { + if (this.aboveTarget()) { + position.bottom = UIStore.instance.windowHeight - targetTop; + chevronFace = ChevronFace.Bottom; + } else { + position.top = targetBottom; + chevronFace = ChevronFace.Top; + } + + // Center the tooltip horizontally with the target's center. + position.left = targetLeft + targetRect.width / 2; + } + + const chevron =
; + + const menuClasses = classNames({ + 'mx_InteractiveTooltip': true, + 'mx_InteractiveTooltip_withChevron_top': chevronFace === ChevronFace.Top, + 'mx_InteractiveTooltip_withChevron_left': chevronFace === ChevronFace.Left, + 'mx_InteractiveTooltip_withChevron_right': chevronFace === ChevronFace.Right, + 'mx_InteractiveTooltip_withChevron_bottom': chevronFace === ChevronFace.Bottom, + }); + + const menuStyle: CSSProperties = {}; + if (contentRect && !this.isOnTheSide) { + menuStyle.left = `-${contentRect.width / 2}px`; + } + + const tooltip =
+
+ { chevron } + { this.props.content } +
+
; + + ReactDOM.render(tooltip, getOrCreateContainer()); + } + + render() { + return this.props.children({ + ref: this.collectTarget, + onMouseOver: this.onTargetMouseOver, + }); + } +} diff --git a/src/components/views/rooms/RecentlyViewedButton.tsx b/src/components/views/rooms/RecentlyViewedButton.tsx new file mode 100644 index 0000000000..9a2630e3ed --- /dev/null +++ b/src/components/views/rooms/RecentlyViewedButton.tsx @@ -0,0 +1,73 @@ +/* +Copyright 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 React, { useRef } from "react"; + +import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { MenuItem } from "../../structures/ContextMenu"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { _t } from "../../../languageHandler"; +import RoomAvatar from "../avatars/RoomAvatar"; +import dis from "../../../dispatcher/dispatcher"; +import InteractiveTooltip, { Direction } from "../elements/InteractiveTooltip"; +import { roomContextDetailsText } from "../../../Rooms"; + +const RecentlyViewedButton = () => { + const tooltipRef = useRef(); + const crumbs = useEventEmitterState(BreadcrumbsStore.instance, UPDATE_EVENT, () => BreadcrumbsStore.instance.rooms); + + const content =
+

{ _t("Recently viewed") }

+
+ { crumbs.map(crumb => { + const contextDetails = roomContextDetailsText(crumb); + + return { + dis.dispatch({ + action: "view_room", + room_id: crumb.roomId, + }); + tooltipRef.current?.hideTooltip(); + }} + > + + +
{ crumb.name }
+ { contextDetails &&
+ { contextDetails } +
} +
+
; + }) } +
+
; + + return + { ({ ref, onMouseOver }) => ( + + ) } + ; +}; + +export default RecentlyViewedButton; diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index f6453d47bb..21cebd7ea0 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -332,10 +332,12 @@ export default class PreferencesUserSettingsTab extends React.Component
{ _t("Preferences") }
-
- { _t("Room list") } - { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } -
+ { !SettingsStore.getValue("feature_breadcrumbs_v2") && +
+ { _t("Room list") } + { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } +
+ }
{ _t("Spaces") } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7a471ccfd3..a905bba635 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -850,6 +850,7 @@ "Show info about bridges in room settings": "Show info about bridges in room settings", "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)", "Meta Spaces": "Meta Spaces", + "Use new room breadcrumbs": "Use new room breadcrumbs", "Don't send read receipts": "Don't send read receipts", "Font size": "Font size", "Use custom size": "Use custom size", @@ -1693,6 +1694,7 @@ "Unknown": "Unknown", "Seen by %(userName)s at %(dateTime)s": "Seen by %(userName)s at %(dateTime)s", "Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Seen by %(displayName)s (%(userName)s) at %(dateTime)s", + "Recently viewed": "Recently viewed", "Replying": "Replying", "Room %(name)s": "Room %(name)s", "Recently visited rooms": "Recently visited rooms", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 3ae546ab56..2e008eaf3d 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -355,6 +355,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { new ReloadOnChangeController(), ]), }, + "feature_breadcrumbs_v2": { + isFeature: true, + labsGroup: LabGroup.Rooms, + supportedLevels: LEVELS_FEATURE, + displayName: _td("Use new room breadcrumbs"), + default: false, + }, "RoomList.backgroundImage": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, @@ -711,6 +718,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Show shortcuts to recently viewed rooms above the room list"), default: true, + controller: new IncompatibleController("feature_breadcrumbs_v2", true), }, "showHiddenEventsInTimeline": { displayName: _td("Show hidden events in timeline"), diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 5f93c8fe68..b9cf33c6f6 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 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. @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsStore from "../settings/SettingsStore"; import { Room } from "matrix-js-sdk/src/models/room"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; + +import SettingsStore from "../settings/SettingsStore"; import { ActionPayload } from "../dispatcher/payloads"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { arrayHasDiff } from "../utils/arrays"; -import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { SettingLevel } from "../settings/SettingLevel"; -import SpaceStore from "./spaces/SpaceStore"; import { Action } from "../dispatcher/actions"; import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; @@ -44,6 +44,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { SettingsStore.monitorSetting("breadcrumb_rooms", null); SettingsStore.monitorSetting("breadcrumbs", null); + SettingsStore.monitorSetting("feature_breadcrumbs_v2", null); } public static get instance(): BreadcrumbsStore { @@ -58,8 +59,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { return this.state.enabled && this.meetsRoomRequirement; } - private get meetsRoomRequirement(): boolean { - return this.matrixClient && this.matrixClient.getVisibleRooms().length >= 20; + public get meetsRoomRequirement(): boolean { + if (SettingsStore.getValue("feature_breadcrumbs_v2")) return true; + return this.matrixClient?.getVisibleRooms().length >= 20; } protected async onAction(payload: ActionPayload) { @@ -69,7 +71,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { const settingUpdatedPayload = payload as SettingUpdatedPayload; if (settingUpdatedPayload.settingName === 'breadcrumb_rooms') { await this.updateRooms(); - } else if (settingUpdatedPayload.settingName === 'breadcrumbs') { + } else if (settingUpdatedPayload.settingName === 'breadcrumbs' || + settingUpdatedPayload.settingName === 'feature_breadcrumbs_v2' + ) { await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); } } else if (payload.action === Action.ViewRoom) { @@ -126,7 +130,6 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } private async appendRoom(room: Room) { - if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return; // hide space rooms let updated = false; const rooms = (this.state.rooms || []).slice(); // cheap clone diff --git a/test/components/views/elements/InteractiveTooltip-test.ts b/test/components/views/elements/InteractiveTooltip-test.ts new file mode 100644 index 0000000000..fcf3da8eb5 --- /dev/null +++ b/test/components/views/elements/InteractiveTooltip-test.ts @@ -0,0 +1,162 @@ +/* +Copyright 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 '../../../skinned-sdk'; +import { Direction, mouseWithinRegion } from "../../../../src/components/views/elements/InteractiveTooltip"; + +describe("InteractiveTooltip", () => { + describe("mouseWithinRegion", () => { + it("direction=left", () => { + const targetRect = { + width: 20, + height: 20, + top: 300, + right: 370, + bottom: 320, + left: 350, + } as DOMRect; + + const contentRect = { + width: 100, + height: 400, + top: 100, + right: 200, + bottom: 500, + left: 100, + } as DOMRect; + + // just within top left corner of contentRect + expect(mouseWithinRegion(101, 101, Direction.Left, targetRect, contentRect)).toBe(true); + // just outside top left corner of contentRect, within buffer + expect(mouseWithinRegion(101, 90, Direction.Left, targetRect, contentRect)).toBe(true); + // just within top right corner of targetRect + expect(mouseWithinRegion(369, 301, Direction.Left, targetRect, contentRect)).toBe(true); + // within the top triangular portion of the trapezoid + expect(mouseWithinRegion(300, 200, Direction.Left, targetRect, contentRect)).toBe(true); + // within the bottom triangular portion of the trapezoid + expect(mouseWithinRegion(300, 350, Direction.Left, targetRect, contentRect)).toBe(true); + // outside the top triangular portion of the trapezoid + expect(mouseWithinRegion(300, 140, Direction.Left, targetRect, contentRect)).toBe(false); + // outside the bottom triangular portion of the trapezoid + expect(mouseWithinRegion(300, 460, Direction.Left, targetRect, contentRect)).toBe(false); + }); + + it("direction=right", () => { + const targetRect = { + width: 20, + height: 20, + top: 300, + right: 370, + bottom: 320, + left: 350, + } as DOMRect; + + const contentRect = { + width: 100, + height: 400, + top: 100, + right: 620, + bottom: 500, + left: 520, + } as DOMRect; + + // just within top right corner of contentRect + expect(mouseWithinRegion(619, 101, Direction.Right, targetRect, contentRect)).toBe(true); + // just outside top right corner of contentRect, within buffer + expect(mouseWithinRegion(619, 90, Direction.Right, targetRect, contentRect)).toBe(true); + // just within top left corner of targetRect + expect(mouseWithinRegion(351, 301, Direction.Right, targetRect, contentRect)).toBe(true); + // within the top triangular portion of the trapezoid + expect(mouseWithinRegion(420, 200, Direction.Right, targetRect, contentRect)).toBe(true); + // within the bottom triangular portion of the trapezoid + expect(mouseWithinRegion(420, 350, Direction.Right, targetRect, contentRect)).toBe(true); + // outside the top triangular portion of the trapezoid + expect(mouseWithinRegion(420, 140, Direction.Right, targetRect, contentRect)).toBe(false); + // outside the bottom triangular portion of the trapezoid + expect(mouseWithinRegion(420, 460, Direction.Right, targetRect, contentRect)).toBe(false); + }); + + it("direction=top", () => { + const targetRect = { + width: 20, + height: 20, + top: 300, + right: 370, + bottom: 320, + left: 350, + } as DOMRect; + + const contentRect = { + width: 400, + height: 100, + top: 100, + right: 550, + bottom: 200, + left: 150, + } as DOMRect; + + // just within top right corner of contentRect + expect(mouseWithinRegion(549, 101, Direction.Top, targetRect, contentRect)).toBe(true); + // just outside top right corner of contentRect, within buffer + expect(mouseWithinRegion(549, 99, Direction.Top, targetRect, contentRect)).toBe(true); + // just within bottom left corner of targetRect + expect(mouseWithinRegion(351, 319, Direction.Top, targetRect, contentRect)).toBe(true); + // within the left triangular portion of the trapezoid + expect(mouseWithinRegion(240, 260, Direction.Top, targetRect, contentRect)).toBe(true); + // within the right triangular portion of the trapezoid + expect(mouseWithinRegion(480, 260, Direction.Top, targetRect, contentRect)).toBe(true); + // outside the left triangular portion of the trapezoid + expect(mouseWithinRegion(220, 260, Direction.Top, targetRect, contentRect)).toBe(false); + // outside the right triangular portion of the trapezoid + expect(mouseWithinRegion(500, 260, Direction.Top, targetRect, contentRect)).toBe(false); + }); + + it("direction=bottom", () => { + const targetRect = { + width: 20, + height: 20, + top: 300, + right: 370, + bottom: 320, + left: 350, + } as DOMRect; + + const contentRect = { + width: 400, + height: 100, + top: 420, + right: 550, + bottom: 520, + left: 150, + } as DOMRect; + + // just within bottom left corner of contentRect + expect(mouseWithinRegion(101, 519, Direction.Bottom, targetRect, contentRect)).toBe(true); + // just outside bottom left corner of contentRect, within buffer + expect(mouseWithinRegion(101, 521, Direction.Bottom, targetRect, contentRect)).toBe(true); + // just within top left corner of targetRect + expect(mouseWithinRegion(351, 301, Direction.Bottom, targetRect, contentRect)).toBe(true); + // within the left triangular portion of the trapezoid + expect(mouseWithinRegion(240, 360, Direction.Bottom, targetRect, contentRect)).toBe(true); + // within the right triangular portion of the trapezoid + expect(mouseWithinRegion(480, 360, Direction.Bottom, targetRect, contentRect)).toBe(true); + // outside the left triangular portion of the trapezoid + expect(mouseWithinRegion(220, 360, Direction.Bottom, targetRect, contentRect)).toBe(false); + // outside the right triangular portion of the trapezoid + expect(mouseWithinRegion(500, 360, Direction.Bottom, targetRect, contentRect)).toBe(false); + }); + }); +});