Replace breadcrumbs with recently viewed menu (#7073)

pull/21833/head
Michael Telatynski 2021-12-01 10:50:06 +00:00 committed by GitHub
parent 757d473971
commit 4a6d46b76a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 992 additions and 34 deletions

View File

@ -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";

View File

@ -37,6 +37,7 @@ limitations under the License.
position: absolute;
font-size: $font-14px;
z-index: 5001;
width: max-content;
}
.mx_ContextualMenu_right {

View File

@ -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;
}

View File

@ -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;

View File

@ -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
}
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.625" cy="8.625" r="6.375" stroke="#C1C6CD" stroke-width="2" stroke-linecap="round"/>
<path d="M8.25 5.625V9.375H11.625" stroke="#C1C6CD" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@ -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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
};
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<IProps, IState> {
};
private renderBreadcrumbs(): React.ReactNode {
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) {
return (
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
@ -349,6 +362,17 @@ export default class LeftPanel extends React.Component<IProps, IState> {
/>;
}
let rightButton: JSX.Element;
if (this.state.showBreadcrumbs === BreadcrumbsMode.Labs) {
rightButton = <RecentlyViewedButton />;
} else if (this.state.activeSpace === MetaSpace.Home) {
rightButton = <AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/>;
}
return (
<div
className="mx_LeftPanel_filterContainer"
@ -363,12 +387,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
/>
{ dialPadButton }
{ this.state.activeSpace === MetaSpace.Home && <AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/> }
{ rightButton }
</div>
);
}

View File

@ -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<HTMLElement>;
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<IProps, IState> {
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<IRect> = {};
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 = <div className={"mx_InteractiveTooltip_chevron_" + chevronFace} />;
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 = <div className="mx_InteractiveTooltip_wrapper" style={{ ...position }}>
<div className={menuClasses} style={menuStyle} ref={this.collectContentRect}>
{ chevron }
{ this.props.content }
</div>
</div>;
ReactDOM.render(tooltip, getOrCreateContainer());
}
render() {
return this.props.children({
ref: this.collectTarget,
onMouseOver: this.onTargetMouseOver,
});
}
}

View File

@ -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<InteractiveTooltip>();
const crumbs = useEventEmitterState(BreadcrumbsStore.instance, UPDATE_EVENT, () => BreadcrumbsStore.instance.rooms);
const content = <div className="mx_RecentlyViewedButton_ContextMenu">
<h4>{ _t("Recently viewed") }</h4>
<div>
{ crumbs.map(crumb => {
const contextDetails = roomContextDetailsText(crumb);
return <MenuItem
key={crumb.roomId}
onClick={() => {
dis.dispatch({
action: "view_room",
room_id: crumb.roomId,
});
tooltipRef.current?.hideTooltip();
}}
>
<RoomAvatar room={crumb} width={24} height={24} />
<span className="mx_RecentlyViewedButton_entry_label">
<div>{ crumb.name }</div>
{ contextDetails && <div className="mx_RecentlyViewedButton_entry_spaces">
{ contextDetails }
</div> }
</span>
</MenuItem>;
}) }
</div>
</div>;
return <InteractiveTooltip content={content} direction={Direction.Right} ref={tooltipRef}>
{ ({ ref, onMouseOver }) => (
<span
className="mx_LeftPanel_recentsButton"
title={_t("Recently viewed")}
ref={ref}
onMouseOver={onMouseOver}
/>
) }
</InteractiveTooltip>;
};
export default RecentlyViewedButton;

View File

@ -332,10 +332,12 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
<div className="mx_SettingsTab mx_PreferencesUserSettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Preferences") }</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Room list") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div>
{ !SettingsStore.getValue("feature_breadcrumbs_v2") &&
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Room list") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div>
}
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>

View File

@ -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",

View File

@ -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"),

View File

@ -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<IState> {
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<IState> {
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<IState> {
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<IState> {
}
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

View File

@ -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);
});
});
});