Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into joriks/eslint-config
This commit is contained in:
commit
b3fa855bd8
454 changed files with 13522 additions and 17619 deletions
|
@ -38,12 +38,13 @@ export default class AutoHideScrollbar extends React.Component {
|
|||
|
||||
render() {
|
||||
return (<div
|
||||
ref={this._collectContainerRef}
|
||||
style={this.props.style}
|
||||
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
|
||||
onScroll={this.props.onScroll}
|
||||
onWheel={this.props.onWheel}
|
||||
>
|
||||
ref={this._collectContainerRef}
|
||||
style={this.props.style}
|
||||
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
|
||||
onScroll={this.props.onScroll}
|
||||
onWheel={this.props.onWheel}
|
||||
tabIndex={this.props.tabIndex}
|
||||
>
|
||||
{ this.props.children }
|
||||
</div>);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
|
@ -20,6 +20,7 @@ import React from 'react';
|
|||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'CompatibilityPage',
|
||||
|
@ -38,14 +39,25 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
return (
|
||||
<div className="mx_CompatibilityPage">
|
||||
<div className="mx_CompatibilityPage_box">
|
||||
<p>{ _t("Sorry, your browser is <b>not</b> able to run Riot.", {}, { 'b': (sub) => <b>{sub}</b> }) } </p>
|
||||
<p>{_t(
|
||||
"Sorry, your browser is <b>not</b> able to run %(brand)s.",
|
||||
{
|
||||
brand,
|
||||
},
|
||||
{
|
||||
'b': (sub) => <b>{sub}</b>,
|
||||
})
|
||||
}</p>
|
||||
<p>
|
||||
{ _t(
|
||||
"Riot uses many advanced browser features, some of which are not available " +
|
||||
"%(brand)s uses many advanced browser features, some of which are not available " +
|
||||
"or experimental in your current browser.",
|
||||
{ brand },
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
|
|
|
@ -16,13 +16,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useRef, useState} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import React, {CSSProperties, useRef, useState} from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
import {Key} from "../../Keyboard";
|
||||
import * as sdk from "../../index";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import {Writeable} from "../../@types/common";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
|
@ -30,8 +29,8 @@ import AccessibleButton from "../views/elements/AccessibleButton";
|
|||
|
||||
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
|
||||
|
||||
function getOrCreateContainer() {
|
||||
let container = document.getElementById(ContextualMenuContainerId);
|
||||
function getOrCreateContainer(): HTMLDivElement {
|
||||
let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
|
@ -43,50 +42,70 @@ function getOrCreateContainer() {
|
|||
}
|
||||
|
||||
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
|
||||
|
||||
interface IPosition {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
export enum ChevronFace {
|
||||
Top = "top",
|
||||
Bottom = "bottom",
|
||||
Left = "left",
|
||||
Right = "right",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
interface IProps extends IPosition {
|
||||
menuWidth?: number;
|
||||
menuHeight?: number;
|
||||
|
||||
chevronOffset?: number;
|
||||
chevronFace?: ChevronFace;
|
||||
|
||||
menuPaddingTop?: number;
|
||||
menuPaddingBottom?: number;
|
||||
menuPaddingLeft?: number;
|
||||
menuPaddingRight?: number;
|
||||
|
||||
zIndex?: number;
|
||||
|
||||
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
|
||||
hasBackground?: boolean;
|
||||
// whether this context menu should be focus managed. If false it must handle itself
|
||||
managed?: boolean;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished();
|
||||
// on resize callback
|
||||
windowResize?();
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contextMenuElem: HTMLDivElement;
|
||||
}
|
||||
|
||||
// Generic ContextMenu Portal wrapper
|
||||
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
|
||||
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
|
||||
export class ContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
top: PropTypes.number,
|
||||
bottom: PropTypes.number,
|
||||
left: PropTypes.number,
|
||||
right: PropTypes.number,
|
||||
menuWidth: PropTypes.number,
|
||||
menuHeight: PropTypes.number,
|
||||
chevronOffset: PropTypes.number,
|
||||
chevronFace: PropTypes.string, // top, bottom, left, right or none
|
||||
// Function to be called on menu close
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
menuPaddingTop: PropTypes.number,
|
||||
menuPaddingRight: PropTypes.number,
|
||||
menuPaddingBottom: PropTypes.number,
|
||||
menuPaddingLeft: PropTypes.number,
|
||||
zIndex: PropTypes.number,
|
||||
|
||||
// If true, insert an invisible screen-sized element behind the
|
||||
// menu that when clicked will close it.
|
||||
hasBackground: PropTypes.bool,
|
||||
|
||||
// on resize callback
|
||||
windowResize: PropTypes.func,
|
||||
|
||||
managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
|
||||
};
|
||||
export class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||
private initialFocus: HTMLElement;
|
||||
|
||||
static defaultProps = {
|
||||
hasBackground: true,
|
||||
managed: true,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
contextMenuElem: null,
|
||||
};
|
||||
|
||||
// persist what had focus when we got initialized so we can return it after
|
||||
this.initialFocus = document.activeElement;
|
||||
this.initialFocus = document.activeElement as HTMLElement;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -94,7 +113,7 @@ export class ContextMenu extends React.Component {
|
|||
this.initialFocus.focus();
|
||||
}
|
||||
|
||||
collectContextMenuRect = (element) => {
|
||||
private collectContextMenuRect = (element) => {
|
||||
// We don't need to clean up when unmounting, so ignore
|
||||
if (!element) return;
|
||||
|
||||
|
@ -111,11 +130,12 @@ export class ContextMenu extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
onContextMenu = (e) => {
|
||||
private onContextMenu = (e) => {
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
|
@ -133,7 +153,20 @@ export class ContextMenu extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
_onMoveFocus = (element, up) => {
|
||||
private onContextMenuPreventBubbling = (e) => {
|
||||
// stop propagation so that any context menu handlers don't leak out of this context menu
|
||||
// but do not inhibit the default browser menu
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Prevent clicks on the background from going through to the component which opened the menu.
|
||||
private onFinished = (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (this.props.onFinished) this.props.onFinished();
|
||||
};
|
||||
|
||||
private onMoveFocus = (element: Element, up: boolean) => {
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
|
||||
do {
|
||||
|
@ -167,25 +200,25 @@ export class ContextMenu extends React.Component {
|
|||
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
(element as HTMLElement).focus();
|
||||
}
|
||||
};
|
||||
|
||||
_onMoveFocusHomeEnd = (element, up) => {
|
||||
private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
|
||||
let results = element.querySelectorAll('[role^="menuitem"]');
|
||||
if (!results) {
|
||||
results = element.querySelectorAll('[tab-index]');
|
||||
}
|
||||
if (results && results.length) {
|
||||
if (up) {
|
||||
results[0].focus();
|
||||
(results[0] as HTMLElement).focus();
|
||||
} else {
|
||||
results[results.length - 1].focus();
|
||||
(results[results.length - 1] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onKeyDown = (ev) => {
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (!this.props.managed) {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.props.onFinished();
|
||||
|
@ -200,19 +233,22 @@ export class ContextMenu extends React.Component {
|
|||
switch (ev.key) {
|
||||
case Key.TAB:
|
||||
case Key.ESCAPE:
|
||||
// close on left and right arrows too for when it is a context menu on a <Toolbar />
|
||||
case Key.ARROW_LEFT:
|
||||
case Key.ARROW_RIGHT:
|
||||
this.props.onFinished();
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this._onMoveFocus(ev.target, true);
|
||||
this.onMoveFocus(ev.target as Element, true);
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this._onMoveFocus(ev.target, false);
|
||||
this.onMoveFocus(ev.target as Element, false);
|
||||
break;
|
||||
case Key.HOME:
|
||||
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
|
||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
|
||||
break;
|
||||
case Key.END:
|
||||
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
|
||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
|
@ -225,9 +261,8 @@ export class ContextMenu extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
renderMenu(hasBackground=this.props.hasBackground) {
|
||||
const position = {};
|
||||
let chevronFace = null;
|
||||
protected renderMenu(hasBackground = this.props.hasBackground) {
|
||||
const position: Partial<Writeable<DOMRect>> = {};
|
||||
const props = this.props;
|
||||
|
||||
if (props.top) {
|
||||
|
@ -236,23 +271,24 @@ export class ContextMenu extends React.Component {
|
|||
position.bottom = props.bottom;
|
||||
}
|
||||
|
||||
let chevronFace: ChevronFace;
|
||||
if (props.left) {
|
||||
position.left = props.left;
|
||||
chevronFace = 'left';
|
||||
chevronFace = ChevronFace.Left;
|
||||
} else {
|
||||
position.right = props.right;
|
||||
chevronFace = 'right';
|
||||
chevronFace = ChevronFace.Right;
|
||||
}
|
||||
|
||||
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
|
||||
|
||||
const chevronOffset = {};
|
||||
const chevronOffset: CSSProperties = {};
|
||||
if (props.chevronFace) {
|
||||
chevronFace = props.chevronFace;
|
||||
}
|
||||
const hasChevron = chevronFace && chevronFace !== "none";
|
||||
const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
|
||||
|
||||
if (chevronFace === 'top' || chevronFace === 'bottom') {
|
||||
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
|
||||
chevronOffset.left = props.chevronOffset;
|
||||
} else if (position.top !== undefined) {
|
||||
const target = position.top;
|
||||
|
@ -282,13 +318,13 @@ export class ContextMenu extends React.Component {
|
|||
'mx_ContextualMenu_right': !hasChevron && position.right,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
|
||||
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
|
||||
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
|
||||
});
|
||||
|
||||
const menuStyle = {};
|
||||
const menuStyle: CSSProperties = {};
|
||||
if (props.menuWidth) {
|
||||
menuStyle.width = props.menuWidth;
|
||||
}
|
||||
|
@ -319,13 +355,28 @@ export class ContextMenu extends React.Component {
|
|||
let background;
|
||||
if (hasBackground) {
|
||||
background = (
|
||||
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={props.onFinished} onContextMenu={this.onContextMenu} />
|
||||
<div
|
||||
className="mx_ContextualMenu_background"
|
||||
style={wrapperStyle}
|
||||
onClick={this.onFinished}
|
||||
onContextMenu={this.onContextMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
|
||||
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
|
||||
<div
|
||||
className="mx_ContextualMenu_wrapper"
|
||||
style={{...position, ...wrapperStyle}}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
>
|
||||
<div
|
||||
className={menuClasses}
|
||||
style={menuStyle}
|
||||
ref={this.collectContextMenuRect}
|
||||
role={this.props.managed ? "menu" : undefined}
|
||||
>
|
||||
{ chevron }
|
||||
{ props.children }
|
||||
</div>
|
||||
|
@ -334,91 +385,13 @@ export class ContextMenu extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): React.ReactChild {
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
}
|
||||
}
|
||||
|
||||
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||
export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} title={label} aria-label={label} aria-haspopup={true} aria-expanded={isExpanded}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
ContextMenuButton.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string,
|
||||
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitem
|
||||
export const MenuItem = ({children, label, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItem.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=group for grouping menu radios/checkboxes
|
||||
export const MenuGroup = ({children, label, ...props}) => {
|
||||
return <div {...props} role="group" aria-label={label}>
|
||||
{ children }
|
||||
</div>;
|
||||
};
|
||||
MenuGroup.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
className: PropTypes.string, // optional
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitemcheckbox
|
||||
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItemCheckbox.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
active: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitemradio
|
||||
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItemRadio.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
active: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
||||
export const toRightOf = (elementRect, chevronOffset=12) => {
|
||||
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
|
||||
const left = elementRect.right + window.pageXOffset + 3;
|
||||
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
|
@ -426,8 +399,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
|
|||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
||||
export const aboveLeftOf = (elementRect, chevronFace="none") => {
|
||||
const menuOptions = { chevronFace };
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
const buttonBottom = elementRect.bottom + window.pageYOffset;
|
||||
|
@ -485,3 +458,13 @@ export function createMenu(ElementClass, props) {
|
|||
|
||||
return {close: onFinished};
|
||||
}
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
|
||||
export {ContextMenuTooltipButton} from "../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
|
||||
export {MenuItem} from "../../accessibility/context_menu/MenuItem";
|
||||
export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
|
||||
export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
|
||||
export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
|
||||
export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";
|
|
@ -38,7 +38,7 @@ const HomePage = () => {
|
|||
}
|
||||
|
||||
const brandingConfig = config.branding;
|
||||
let logoUrl = "themes/riot/img/logos/riot-logo.svg";
|
||||
let logoUrl = "themes/element/img/logos/element-logo.svg";
|
||||
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
|
||||
logoUrl = brandingConfig.authHeaderLogoUrl;
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ const HomePage = () => {
|
|||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
|
||||
<div className="mx_HomePage_default_wrapper">
|
||||
<img src={logoUrl} alt="Riot" />
|
||||
<img src={logoUrl} alt={config.brand || "Riot"} />
|
||||
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Riot" }) }</h1>
|
||||
<h4>{ _t("Liberate your communication") }</h4>
|
||||
<div className="mx_HomePage_default_buttons">
|
||||
|
|
|
@ -192,7 +192,7 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
ref={this._collectScrollerComponent}
|
||||
wrappedRef={this._collectScroller}
|
||||
onWheel={this.onMouseWheel}
|
||||
{... this.props}
|
||||
{...this.props}
|
||||
>
|
||||
{ leftOverflowIndicator }
|
||||
{ this.props.children }
|
||||
|
|
|
@ -1,305 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Key } from '../../Keyboard';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import * as VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
import Analytics from "../../Analytics";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
|
||||
const LeftPanel = createReactClass({
|
||||
displayName: 'LeftPanel',
|
||||
|
||||
// NB. If you add props, don't forget to update
|
||||
// shouldComponentUpdate!
|
||||
propTypes: {
|
||||
collapsed: PropTypes.bool.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
searchFilter: '',
|
||||
breadcrumbs: false,
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this.focusedElement = null;
|
||||
|
||||
this._breadcrumbsWatcherRef = SettingsStore.watchSetting(
|
||||
"breadcrumbs", null, this._onBreadcrumbsChanged);
|
||||
this._tagPanelWatcherRef = SettingsStore.watchSetting(
|
||||
"TagPanel.enableTagPanel", null, () => this.forceUpdate());
|
||||
|
||||
const useBreadcrumbs = !!SettingsStore.getValue("breadcrumbs");
|
||||
Analytics.setBreadcrumbs(useBreadcrumbs);
|
||||
this.setState({breadcrumbs: useBreadcrumbs});
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
SettingsStore.unwatchSetting(this._breadcrumbsWatcherRef);
|
||||
SettingsStore.unwatchSetting(this._tagPanelWatcherRef);
|
||||
},
|
||||
|
||||
shouldComponentUpdate: function(nextProps, nextState) {
|
||||
// MatrixChat will update whenever the user switches
|
||||
// rooms, but propagating this change all the way down
|
||||
// the react tree is quite slow, so we cut this off
|
||||
// here. The RoomTiles listen for the room change
|
||||
// events themselves to know when to update.
|
||||
// We just need to update if any of these things change.
|
||||
if (
|
||||
this.props.collapsed !== nextProps.collapsed ||
|
||||
this.props.disabled !== nextProps.disabled
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.state.searchFilter !== nextState.searchFilter) {
|
||||
return true;
|
||||
}
|
||||
if (this.state.searchExpanded !== nextState.searchExpanded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.breadcrumbs !== this.state.breadcrumbs) {
|
||||
Analytics.setBreadcrumbs(this.state.breadcrumbs);
|
||||
}
|
||||
},
|
||||
|
||||
_onBreadcrumbsChanged: function(settingName, roomId, level, valueAtLevel, value) {
|
||||
// Features are only possible at a single level, so we can get away with using valueAtLevel.
|
||||
// The SettingsStore runs on the same tick as the update, so `value` will be wrong.
|
||||
this.setState({breadcrumbs: valueAtLevel});
|
||||
|
||||
// For some reason the setState doesn't trigger a render of the component, so force one.
|
||||
// Probably has to do with the change happening outside of a change detector cycle.
|
||||
this.forceUpdate();
|
||||
},
|
||||
|
||||
_onFocus: function(ev) {
|
||||
this.focusedElement = ev.target;
|
||||
},
|
||||
|
||||
_onBlur: function(ev) {
|
||||
this.focusedElement = null;
|
||||
},
|
||||
|
||||
_onFilterKeyDown: function(ev) {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
switch (ev.key) {
|
||||
// On enter of rooms filter select and activate first room if such one exists
|
||||
case Key.ENTER: {
|
||||
const firstRoom = ev.target.closest(".mx_LeftPanel").querySelector(".mx_RoomTile");
|
||||
if (firstRoom) {
|
||||
firstRoom.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_onKeyDown: function(ev) {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_UP:
|
||||
this._onMoveFocus(ev, true, true);
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this._onMoveFocus(ev, false, true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_onMoveFocus: function(ev, up, trap) {
|
||||
let element = this.focusedElement;
|
||||
|
||||
// unclear why this isn't needed
|
||||
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
|
||||
// this.focusDirection = up;
|
||||
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
let classes;
|
||||
|
||||
do {
|
||||
const child = up ? element.lastElementChild : element.firstElementChild;
|
||||
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||
|
||||
if (descending) {
|
||||
if (child) {
|
||||
element = child;
|
||||
} else if (sibling) {
|
||||
element = sibling;
|
||||
} else {
|
||||
descending = false;
|
||||
element = element.parentElement;
|
||||
}
|
||||
} else {
|
||||
if (sibling) {
|
||||
element = sibling;
|
||||
descending = true;
|
||||
} else {
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
classes = element.classList;
|
||||
}
|
||||
} while (element && !(
|
||||
classes.contains("mx_RoomTile") ||
|
||||
classes.contains("mx_RoomSubList_label") ||
|
||||
classes.contains("mx_LeftPanel_filterRooms")));
|
||||
|
||||
if (element) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
element.focus();
|
||||
this.focusedElement = element;
|
||||
} else if (trap) {
|
||||
// if navigation is via up/down arrow-keys, trap in the widget so it doesn't send to composer
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
onSearch: function(term) {
|
||||
this.setState({ searchFilter: term });
|
||||
},
|
||||
|
||||
onSearchCleared: function(source) {
|
||||
if (source === "keyboard") {
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
this.setState({searchExpanded: false});
|
||||
},
|
||||
|
||||
collectRoomList: function(ref) {
|
||||
this._roomList = ref;
|
||||
},
|
||||
|
||||
_onSearchFocus: function() {
|
||||
this.setState({searchExpanded: true});
|
||||
},
|
||||
|
||||
_onSearchBlur: function(event) {
|
||||
if (event.target.value.length === 0) {
|
||||
this.setState({searchExpanded: false});
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const RoomList = sdk.getComponent('rooms.RoomList');
|
||||
const RoomBreadcrumbs = sdk.getComponent('rooms.RoomBreadcrumbs');
|
||||
const TagPanel = sdk.getComponent('structures.TagPanel');
|
||||
const CustomRoomTagPanel = sdk.getComponent('structures.CustomRoomTagPanel');
|
||||
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
|
||||
const SearchBox = sdk.getComponent('structures.SearchBox');
|
||||
const CallPreview = sdk.getComponent('voip.CallPreview');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const tagPanelEnabled = SettingsStore.getValue("TagPanel.enableTagPanel");
|
||||
let tagPanelContainer;
|
||||
|
||||
const isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
|
||||
|
||||
if (tagPanelEnabled) {
|
||||
tagPanelContainer = (<div className="mx_LeftPanel_tagPanelContainer">
|
||||
<TagPanel />
|
||||
{ isCustomTagsEnabled ? <CustomRoomTagPanel /> : undefined }
|
||||
</div>);
|
||||
}
|
||||
|
||||
const containerClasses = classNames(
|
||||
"mx_LeftPanel_container", "mx_fadable",
|
||||
{
|
||||
"collapsed": this.props.collapsed,
|
||||
"mx_LeftPanel_container_hasTagPanel": tagPanelEnabled,
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
},
|
||||
);
|
||||
|
||||
let exploreButton;
|
||||
if (!this.props.collapsed) {
|
||||
exploreButton = (
|
||||
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
|
||||
<AccessibleButton onClick={() => dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const searchBox = (<SearchBox
|
||||
className="mx_LeftPanel_filterRooms"
|
||||
enableRoomSearchFocus={true}
|
||||
blurredPlaceholder={ _t('Filter') }
|
||||
placeholder={ _t('Filter rooms…') }
|
||||
onKeyDown={this._onFilterKeyDown}
|
||||
onSearch={ this.onSearch }
|
||||
onCleared={ this.onSearchCleared }
|
||||
onFocus={this._onSearchFocus}
|
||||
onBlur={this._onSearchBlur}
|
||||
collapsed={this.props.collapsed} />);
|
||||
|
||||
let breadcrumbs;
|
||||
if (this.state.breadcrumbs) {
|
||||
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
||||
}
|
||||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this._onKeyDown}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
ref={this.collectRoomList}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ConferenceHandler={VectorConferenceHandler} />;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{ tagPanelContainer }
|
||||
<aside className="mx_LeftPanel dark-panel">
|
||||
<TopLeftMenuButton collapsed={this.props.collapsed} />
|
||||
{ breadcrumbs }
|
||||
<CallPreview ConferenceHandler={VectorConferenceHandler} />
|
||||
<div className="mx_LeftPanel_exploreAndFilterRow" onKeyDown={this._onKeyDown} onFocus={this._onFocus} onBlur={this._onBlur}>
|
||||
{ exploreButton }
|
||||
{ searchBox }
|
||||
</div>
|
||||
{roomList}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default LeftPanel;
|
412
src/components/structures/LeftPanel.tsx
Normal file
412
src/components/structures/LeftPanel.tsx
Normal file
|
@ -0,0 +1,412 @@
|
|||
/*
|
||||
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 TagPanel from "./TagPanel";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList from "../views/rooms/RoomList";
|
||||
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import UserMenu from "./UserMenu";
|
||||
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 SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
|
||||
import {Key} from "../../Keyboard";
|
||||
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
searchFilter: string;
|
||||
showBreadcrumbs: boolean;
|
||||
showTagPanel: boolean;
|
||||
}
|
||||
|
||||
// List of CSS classes which should be included in keyboard navigation within the room list
|
||||
const cssClasses = [
|
||||
"mx_RoomSearch_input",
|
||||
"mx_RoomSearch_icon", // minimized <RoomSearch />
|
||||
"mx_RoomSublist_headerText",
|
||||
"mx_RoomTile",
|
||||
"mx_RoomSublist_showNButton",
|
||||
];
|
||||
|
||||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private tagPanelWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
searchFilter: "",
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
|
||||
});
|
||||
|
||||
// We watch the middle panel because we don't actually get resized, the middle panel does.
|
||||
// We listen to the noisy channel to avoid choppy reaction times.
|
||||
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
|
||||
private onSearch = (term: string): void => {
|
||||
this.setState({searchFilter: term});
|
||||
};
|
||||
|
||||
private onExplore = () => {
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
private onBreadcrumbsUpdate = () => {
|
||||
const newVal = BreadcrumbsStore.instance.visible;
|
||||
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) {
|
||||
if (this.isDoingStickyHeaders) return;
|
||||
this.isDoingStickyHeaders = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
this.doStickyHeaders(list);
|
||||
this.isDoingStickyHeaders = false;
|
||||
});
|
||||
}
|
||||
|
||||
private doStickyHeaders(list: HTMLDivElement) {
|
||||
const topEdge = list.scrollTop;
|
||||
const bottomEdge = list.offsetHeight + list.scrollTop;
|
||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
|
||||
|
||||
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
|
||||
const headerStickyWidth = list.clientWidth - headerRightMargin;
|
||||
|
||||
// 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;
|
||||
let firstBottomHeader;
|
||||
for (const sublist of sublists) {
|
||||
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
|
||||
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");
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyTop || style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_sticky");
|
||||
}
|
||||
|
||||
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 (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: React.MouseEvent<HTMLDivElement>) => {
|
||||
const list = ev.target as HTMLDivElement;
|
||||
this.handleStickyHeaders(list);
|
||||
};
|
||||
|
||||
private onResize = () => {
|
||||
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
|
||||
this.handleStickyHeaders(this.listContainerRef.current);
|
||||
};
|
||||
|
||||
private onFocus = (ev: React.FocusEvent) => {
|
||||
this.focusedElement = ev.target;
|
||||
};
|
||||
|
||||
private onBlur = () => {
|
||||
this.focusedElement = null;
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.onMoveFocus(ev.key === Key.ARROW_UP);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private onEnter = () => {
|
||||
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile");
|
||||
if (firstRoom) {
|
||||
firstRoom.click();
|
||||
return true; // to get the field to clear
|
||||
}
|
||||
};
|
||||
|
||||
private onMoveFocus = (up: boolean) => {
|
||||
let element = this.focusedElement;
|
||||
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
let classes: DOMTokenList;
|
||||
|
||||
do {
|
||||
const child = up ? element.lastElementChild : element.firstElementChild;
|
||||
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||
|
||||
if (descending) {
|
||||
if (child) {
|
||||
element = child;
|
||||
} else if (sibling) {
|
||||
element = sibling;
|
||||
} else {
|
||||
descending = false;
|
||||
element = element.parentElement;
|
||||
}
|
||||
} else {
|
||||
if (sibling) {
|
||||
element = sibling;
|
||||
descending = true;
|
||||
} else {
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
classes = element.classList;
|
||||
}
|
||||
} while (element && !cssClasses.some(c => classes.contains(c)));
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
this.focusedElement = element;
|
||||
}
|
||||
};
|
||||
|
||||
private renderHeader(): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_LeftPanel_userHeader">
|
||||
<UserMenu isMinimized={this.props.isMinimized} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderBreadcrumbs(): React.ReactNode {
|
||||
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
|
||||
return (
|
||||
<IndicatorScrollbar
|
||||
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||
verticalScrollsHorizontally={true}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RoomBreadcrumbs />
|
||||
</IndicatorScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSearchExplore(): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_LeftPanel_filterContainer"
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
<RoomSearch
|
||||
onQueryUpdate={this.onSearch}
|
||||
isMinimized={this.props.isMinimized}
|
||||
onVerticalArrow={this.onKeyDown}
|
||||
onEnter={this.onEnter}
|
||||
/>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_LeftPanel_exploreButton"
|
||||
onClick={this.onExplore}
|
||||
title={_t("Explore rooms")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const tagPanel = !this.state.showTagPanel ? null : (
|
||||
<div className="mx_LeftPanel_tagPanelContainer">
|
||||
<TagPanel/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={null}
|
||||
collapsed={false}
|
||||
searchFilter={this.state.searchFilter}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
isMinimized={this.props.isMinimized}
|
||||
onResize={this.onResize}
|
||||
/>;
|
||||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel": true,
|
||||
"mx_LeftPanel_hasTagPanel": !!tagPanel,
|
||||
"mx_LeftPanel_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
const roomListClasses = classNames(
|
||||
"mx_LeftPanel_actualRoomListContainer",
|
||||
"mx_AutoHideScrollbar",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{tagPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
{this.renderBreadcrumbs()}
|
||||
<div className="mx_LeftPanel_roomListWrapper">
|
||||
<div
|
||||
className={roomListClasses}
|
||||
onScroll={this.onScroll}
|
||||
ref={this.listContainerRef}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{roomList}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,232 +0,0 @@
|
|||
/*
|
||||
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 TagPanel from "./TagPanel";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList2 from "../views/rooms/RoomList2";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import UserMenu from "./UserMenu";
|
||||
import RoomSearch from "./RoomSearch";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
|
||||
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
searchFilter: string; // TODO: Move search into room list?
|
||||
showBreadcrumbs: boolean;
|
||||
showTagPanel: boolean;
|
||||
}
|
||||
|
||||
export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private tagPanelWatcherRef: string;
|
||||
|
||||
// TODO: Properly support TagPanel
|
||||
// TODO: Properly support searching/filtering
|
||||
// TODO: Properly support breadcrumbs
|
||||
// TODO: a11y
|
||||
// TODO: actually make this useful in general (match design proposals)
|
||||
// TODO: Fadable support (is this still needed?)
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
searchFilter: "",
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
|
||||
});
|
||||
|
||||
// We watch the middle panel because we don't actually get resized, the middle panel does.
|
||||
// We listen to the noisy channel to avoid choppy reaction times.
|
||||
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
|
||||
private onSearch = (term: string): void => {
|
||||
this.setState({searchFilter: term});
|
||||
};
|
||||
|
||||
private onExplore = () => {
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
private onBreadcrumbsUpdate = () => {
|
||||
const newVal = BreadcrumbsStore.instance.visible;
|
||||
if (newVal !== this.state.showBreadcrumbs) {
|
||||
this.setState({showBreadcrumbs: newVal});
|
||||
}
|
||||
};
|
||||
|
||||
private handleStickyHeaders(list: HTMLDivElement) {
|
||||
const rlRect = list.getBoundingClientRect();
|
||||
const bottom = rlRect.bottom;
|
||||
const top = rlRect.top;
|
||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
|
||||
const headerHeight = 32; // Note: must match the CSS!
|
||||
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
|
||||
|
||||
const headerStickyWidth = rlRect.width - headerRightMargin;
|
||||
|
||||
let gotBottom = false;
|
||||
for (const sublist of sublists) {
|
||||
const slRect = sublist.getBoundingClientRect();
|
||||
|
||||
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
|
||||
|
||||
if (slRect.top + headerHeight > bottom && !gotBottom) {
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
|
||||
header.style.width = `${headerStickyWidth}px`;
|
||||
header.style.top = `unset`;
|
||||
gotBottom = true;
|
||||
} else if (slRect.top < top) {
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
|
||||
header.style.width = `${headerStickyWidth}px`;
|
||||
header.style.top = `${rlRect.top}px`;
|
||||
} else {
|
||||
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
|
||||
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
|
||||
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
|
||||
header.style.width = `unset`;
|
||||
header.style.top = `unset`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Apply this on resize, init, etc for reliability
|
||||
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
const list = ev.target as HTMLDivElement;
|
||||
this.handleStickyHeaders(list);
|
||||
};
|
||||
|
||||
private onResize = () => {
|
||||
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
|
||||
this.handleStickyHeaders(this.listContainerRef.current);
|
||||
};
|
||||
|
||||
private renderHeader(): React.ReactNode {
|
||||
// TODO: Update when profile info changes
|
||||
// TODO: Presence
|
||||
// TODO: Breadcrumbs toggle
|
||||
// TODO: Menu button
|
||||
|
||||
let breadcrumbs;
|
||||
if (this.state.showBreadcrumbs) {
|
||||
breadcrumbs = (
|
||||
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
|
||||
{this.props.isMinimized ? null : <RoomBreadcrumbs2 />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_LeftPanel2_userHeader">
|
||||
<UserMenu isMinimized={this.props.isMinimized} />
|
||||
{breadcrumbs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderSearchExplore(): React.ReactNode {
|
||||
// TODO: Collapsed support
|
||||
|
||||
return (
|
||||
<div className="mx_LeftPanel2_filterContainer">
|
||||
<RoomSearch onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} />
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
className='mx_LeftPanel2_exploreButton'
|
||||
onClick={this.onExplore}
|
||||
alt={_t("Explore rooms")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const tagPanel = !this.state.showTagPanel ? null : (
|
||||
<div className="mx_LeftPanel2_tagPanelContainer">
|
||||
<TagPanel/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// TODO: Improve props for RoomList2
|
||||
const roomList = <RoomList2
|
||||
onKeyDown={() => {/*TODO*/}}
|
||||
resizeNotifier={null}
|
||||
collapsed={false}
|
||||
searchFilter={this.state.searchFilter}
|
||||
onFocus={() => {/*TODO*/}}
|
||||
onBlur={() => {/*TODO*/}}
|
||||
isMinimized={this.props.isMinimized}
|
||||
/>;
|
||||
|
||||
// TODO: Conference handling / calls
|
||||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel2": true,
|
||||
"mx_LeftPanel2_hasTagPanel": !!tagPanel,
|
||||
"mx_LeftPanel2_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{tagPanel}
|
||||
<aside className="mx_LeftPanel2_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
<div
|
||||
className="mx_LeftPanel2_actualRoomListContainer"
|
||||
onScroll={this.onScroll}
|
||||
ref={this.listContainerRef}
|
||||
>{roomList}</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -19,7 +19,6 @@ limitations under the License.
|
|||
import * as React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
|
||||
|
@ -41,7 +40,6 @@ import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
|
|||
import HomePage from "./HomePage";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import { RoomListStoreTempProxy } from "../../stores/room-list/RoomListStoreTempProxy";
|
||||
import { DefaultTagID } from "../../stores/room-list/models";
|
||||
import {
|
||||
showToast as showSetPasswordToast,
|
||||
|
@ -52,7 +50,10 @@ import {
|
|||
hideToast as hideServerLimitToast
|
||||
} from "../../toasts/ServerLimitToast";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import LeftPanel2 from "./LeftPanel2";
|
||||
import LeftPanel from "./LeftPanel";
|
||||
import CallContainer from '../views/voip/CallContainer';
|
||||
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -146,6 +147,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
|
||||
protected readonly _sessionStore: sessionStore;
|
||||
protected readonly _sessionStoreToken: { remove: () => void };
|
||||
protected readonly _compactLayoutWatcherRef: string;
|
||||
protected resizer: Resizer;
|
||||
|
||||
constructor(props, context) {
|
||||
|
@ -177,6 +179,10 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this._matrixClient.on("sync", this.onSync);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = React.createRef();
|
||||
|
@ -194,6 +200,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
|
||||
if (this._sessionStoreToken) {
|
||||
this._sessionStoreToken.remove();
|
||||
}
|
||||
|
@ -263,16 +270,17 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
onAccountData = (event) => {
|
||||
if (event.getType() === "im.vector.web.settings") {
|
||||
this.setState({
|
||||
useCompactLayout: event.getContent().useCompactLayout,
|
||||
});
|
||||
}
|
||||
if (event.getType() === "m.ignored_user_list") {
|
||||
dis.dispatch({action: "ignore_state_changed"});
|
||||
}
|
||||
};
|
||||
|
||||
onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => {
|
||||
this.setState({
|
||||
useCompactLayout: valueAtLevel,
|
||||
});
|
||||
};
|
||||
|
||||
onSync = (syncState, oldSyncState, data) => {
|
||||
const oldErrCode = (
|
||||
this.state.syncErrorData &&
|
||||
|
@ -300,8 +308,8 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
onRoomStateEvents = (ev, state) => {
|
||||
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
if (roomLists[DefaultTagID.ServerNotice] && roomLists[DefaultTagID.ServerNotice].some(r => r.roomId === ev.getRoomId())) {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) {
|
||||
this._updateServerNoticeEvents();
|
||||
}
|
||||
};
|
||||
|
@ -320,11 +328,11 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
_updateServerNoticeEvents = async () => {
|
||||
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
if (!roomLists[DefaultTagID.ServerNotice]) return [];
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (!serverNoticeList) return [];
|
||||
|
||||
const events = [];
|
||||
for (const room of roomLists[DefaultTagID.ServerNotice]) {
|
||||
for (const room of serverNoticeList) {
|
||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||
|
||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||
|
@ -402,20 +410,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
_onKeyDown = (ev) => {
|
||||
/*
|
||||
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
|
||||
// Will need to find a better meta key if anyone actually cares about using this.
|
||||
if (ev.altKey && ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) {
|
||||
dis.dispatch({
|
||||
action: 'view_indexed_room',
|
||||
roomIndex: ev.keyCode - 49,
|
||||
});
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
let handled = false;
|
||||
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
|
||||
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
|
||||
|
@ -467,8 +461,8 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
dis.dispatch({
|
||||
action: 'view_room_delta',
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: ev.key === Key.ARROW_UP ? -1 : 1,
|
||||
unread: ev.shiftKey,
|
||||
});
|
||||
|
@ -613,7 +607,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const LeftPanel = sdk.getComponent('structures.LeftPanel');
|
||||
const RoomView = sdk.getComponent('structures.RoomView');
|
||||
const UserView = sdk.getComponent('structures.UserView');
|
||||
const GroupView = sdk.getComponent('structures.GroupView');
|
||||
|
@ -667,22 +660,12 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||
}
|
||||
|
||||
let leftPanel = (
|
||||
const leftPanel = (
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
);
|
||||
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
|
||||
// TODO: Supply props like collapsed and disabled to LeftPanel2
|
||||
leftPanel = (
|
||||
<LeftPanel2
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
|
@ -703,6 +686,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
<CallContainer />
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -23,7 +23,6 @@ import * as Matrix from "matrix-js-sdk";
|
|||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { isCryptoAvailable } from 'matrix-js-sdk/src/crypto';
|
||||
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
|
||||
import 'focus-visible';
|
||||
// what-input helps improve keyboard accessibility
|
||||
|
@ -51,7 +50,7 @@ import PageTypes from '../../PageTypes';
|
|||
import { getHomePageUrl } from '../../utils/pages';
|
||||
|
||||
import createRoom from "../../createRoom";
|
||||
import { _t, getCurrentLanguage } from '../../languageHandler';
|
||||
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
|
||||
import SettingsStore, { SettingLevel } from "../../settings/SettingsStore";
|
||||
import ThemeController from "../../settings/controllers/ThemeController";
|
||||
import { startAnyRegistrationFlow } from "../../Registration.js";
|
||||
|
@ -75,6 +74,7 @@ import {
|
|||
} from "../../toasts/AnalyticsToast";
|
||||
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -461,7 +461,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
onAction = (payload) => {
|
||||
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
||||
// Start the onboarding process for certain actions
|
||||
|
@ -555,6 +554,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
case 'leave_room':
|
||||
this.leaveRoom(payload.room_id);
|
||||
break;
|
||||
case 'forget_room':
|
||||
this.forgetRoom(payload.room_id);
|
||||
break;
|
||||
case 'reject_invite':
|
||||
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
|
||||
title: _t('Reject invitation'),
|
||||
|
@ -597,15 +599,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case 'view_prev_room':
|
||||
this.viewNextRoom(-1);
|
||||
break;
|
||||
case 'view_next_room':
|
||||
this.viewNextRoom(1);
|
||||
break;
|
||||
case 'view_indexed_room':
|
||||
this.viewIndexedRoom(payload.roomIndex);
|
||||
break;
|
||||
case Action.ViewUserSettings: {
|
||||
const tabPayload = payload as OpenToTabPayload;
|
||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
|
@ -679,12 +675,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
case 'hide_left_panel':
|
||||
this.setState({
|
||||
collapseLhs: true,
|
||||
}, () => {
|
||||
this.state.resizeNotifier.notifyLeftHandleResized();
|
||||
});
|
||||
break;
|
||||
case 'focus_room_filter': // for CtrlOrCmd+K to work by expanding the left panel first
|
||||
case 'show_left_panel':
|
||||
this.setState({
|
||||
collapseLhs: false,
|
||||
}, () => {
|
||||
this.state.resizeNotifier.notifyLeftHandleResized();
|
||||
});
|
||||
break;
|
||||
case 'panel_disable': {
|
||||
|
@ -813,19 +813,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
// TODO: Move to RoomViewStore
|
||||
private viewIndexedRoom(roomIndex: number) {
|
||||
const allRooms = RoomListSorter.mostRecentActivityFirst(
|
||||
MatrixClientPeg.get().getRooms(),
|
||||
);
|
||||
if (allRooms[roomIndex]) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: allRooms[roomIndex].roomId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// switch view to the given room
|
||||
//
|
||||
// @param {Object} roomInfo Object containing data about the room to be joined
|
||||
|
@ -1080,7 +1067,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private leaveRoom(roomId: string) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this.leaveRoomWarnings(roomId);
|
||||
|
||||
|
@ -1144,6 +1130,21 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private forgetRoom(roomId: string) {
|
||||
MatrixClientPeg.get().forget(roomId).then(() => {
|
||||
// Switch to another room view if we're currently viewing the historical room
|
||||
if (this.state.currentRoomId === roomId) {
|
||||
dis.dispatch({ action: "view_next_room" });
|
||||
}
|
||||
}).catch((err) => {
|
||||
const errCode = err.errcode || _td("unknown error code");
|
||||
Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, {
|
||||
title: _t("Failed to forget room %(errCode)s", {errCode}),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a chat with the welcome user, if the user doesn't already have one
|
||||
* @returns {string} The room ID of the new room, or null if no room was created
|
||||
|
@ -1392,7 +1393,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
return;
|
||||
}
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
|
||||
title: _t('Signed Out'),
|
||||
description: _t('For security, this session has been signed out. Please sign in again.'),
|
||||
|
@ -1462,19 +1462,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
});
|
||||
cli.on("crypto.warning", (type) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
switch (type) {
|
||||
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
|
||||
const brand = SdkConfig.get().brand;
|
||||
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
|
||||
title: _t('Old cryptography data detected'),
|
||||
description: _t(
|
||||
"Data from an older version of Riot has been detected. " +
|
||||
"Data from an older version of %(brand)s has been detected. " +
|
||||
"This will have caused end-to-end cryptography to malfunction " +
|
||||
"in the older version. End-to-end encrypted messages exchanged " +
|
||||
"recently whilst using the older version may not be decryptable " +
|
||||
"in this version. This may also cause messages exchanged with this " +
|
||||
"version to fail. If you experience problems, log out and back in " +
|
||||
"again. To retain message history, export and re-import your keys.",
|
||||
{ brand },
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
@ -1839,7 +1840,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else {
|
||||
subtitle = `${this.subTitleStatus} ${subtitle}`;
|
||||
}
|
||||
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`;
|
||||
document.title = `${SdkConfig.get().brand} ${subtitle}`;
|
||||
}
|
||||
|
||||
updateStatusIndicator(state: string, prevState: string) {
|
||||
|
@ -1932,11 +1933,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
getFragmentAfterLogin() {
|
||||
let fragmentAfterLogin = "";
|
||||
if (this.props.initialScreenAfterLogin &&
|
||||
const initialScreenAfterLogin = this.props.initialScreenAfterLogin;
|
||||
if (initialScreenAfterLogin &&
|
||||
// XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop
|
||||
!["welcome", "login", "register"].includes(this.props.initialScreenAfterLogin.screen)
|
||||
!["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen)
|
||||
) {
|
||||
fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`;
|
||||
fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`;
|
||||
}
|
||||
return fragmentAfterLogin;
|
||||
}
|
||||
|
|
|
@ -388,8 +388,11 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<li key={"readMarker_"+eventId} ref={this._readMarkerNode}
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
<li key={"readMarker_"+eventId}
|
||||
ref={this._readMarkerNode}
|
||||
className="mx_RoomView_myReadMarker_container"
|
||||
data-scroll-tokens={eventId}
|
||||
>
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
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.
|
||||
|
@ -19,6 +20,7 @@ import React from 'react';
|
|||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import AccessibleButton from '../views/elements/AccessibleButton';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
@ -60,6 +62,7 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
const GroupTile = sdk.getComponent("groups.GroupTile");
|
||||
|
@ -77,7 +80,8 @@ export default createReactClass({
|
|||
<div className="mx_MyGroups_microcopy">
|
||||
<p>
|
||||
{ _t(
|
||||
"Did you know: you can use communities to filter your Riot.im experience!",
|
||||
"Did you know: you can use communities to filter your %(brand)s experience!",
|
||||
{ brand },
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
|
@ -25,6 +25,7 @@ import Modal from "../../Modal";
|
|||
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
|
@ -74,7 +75,7 @@ export default createReactClass({
|
|||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
}, (err) => {
|
||||
console.warn(`error loading thirdparty protocols: ${err}`);
|
||||
console.warn(`error loading third party protocols: ${err}`);
|
||||
this.setState({protocolsLoading: false});
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// Guests currently aren't allowed to use this API, so
|
||||
|
@ -83,10 +84,12 @@ export default createReactClass({
|
|||
return;
|
||||
}
|
||||
track('Failed to get protocol list from homeserver');
|
||||
const brand = SdkConfig.get().brand;
|
||||
this.setState({
|
||||
error: _t(
|
||||
'Riot failed to get the protocol list from the homeserver. ' +
|
||||
'%(brand)s failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
{ brand },
|
||||
),
|
||||
});
|
||||
});
|
||||
|
@ -173,12 +176,13 @@ export default createReactClass({
|
|||
|
||||
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
|
||||
track('Failed to get public room list');
|
||||
const brand = SdkConfig.get().brand;
|
||||
this.setState({
|
||||
loading: false,
|
||||
error:
|
||||
`${_t('Riot failed to get the public room list.')} ` +
|
||||
`${(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')}`
|
||||
,
|
||||
error: (
|
||||
_t('%(brand)s failed to get the public room list.', { brand }) +
|
||||
(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
@ -314,9 +318,10 @@ export default createReactClass({
|
|||
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
|
||||
if (!fields) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const brand = SdkConfig.get().brand;
|
||||
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
|
||||
title: _t('Unable to join network'),
|
||||
description: _t('Riot does not know how to join a room on this network'),
|
||||
description: _t('%(brand)s does not know how to join a room on this network', { brand }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -25,17 +25,11 @@ import { Key } from "../../Keyboard";
|
|||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
|
||||
interface IProps {
|
||||
onQueryUpdate: (newQuery: string) => void;
|
||||
isMinimized: boolean;
|
||||
onVerticalArrow(ev: React.KeyboardEvent): void;
|
||||
onEnter(ev: React.KeyboardEvent): boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -78,6 +72,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private openSearch = () => {
|
||||
defaultDispatcher.dispatch({action: "show_left_panel"});
|
||||
defaultDispatcher.dispatch({action: "focus_room_filter"});
|
||||
};
|
||||
|
||||
private onChange = () => {
|
||||
|
@ -101,7 +96,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
ev.target.select();
|
||||
};
|
||||
|
||||
private onBlur = () => {
|
||||
private onBlur = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
this.setState({focused: false});
|
||||
};
|
||||
|
||||
|
@ -109,6 +104,16 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
if (ev.key === Key.ESCAPE) {
|
||||
this.clearInput();
|
||||
defaultDispatcher.fire(Action.FocusComposer);
|
||||
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
|
||||
this.props.onVerticalArrow(ev);
|
||||
} else if (ev.key === Key.ENTER) {
|
||||
const shouldClear = this.props.onEnter(ev);
|
||||
if (shouldClear) {
|
||||
// wrap in set immediate to delay it so that we don't clear the filter & then change room
|
||||
setImmediate(() => {
|
||||
this.clearInput();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -144,7 +149,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
let clearButton = (
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
className='mx_RoomSearch_clearButton'
|
||||
title={_t("Clear filter")}
|
||||
className="mx_RoomSearch_clearButton"
|
||||
onClick={this.clearInput}
|
||||
/>
|
||||
);
|
||||
|
@ -152,8 +158,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
if (this.props.isMinimized) {
|
||||
icon = (
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
className='mx_RoomSearch_icon'
|
||||
title={_t("Search rooms")}
|
||||
className="mx_RoomSearch_icon"
|
||||
onClick={this.openSearch}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -173,7 +173,7 @@ export default createReactClass({
|
|||
if (this.props.hasActiveCall) {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
return (
|
||||
<TintableSvg src={require("../../../res/img/sound-indicator.svg")} width="23" height="20" />
|
||||
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,496 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 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, {createRef} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import * as Unread from '../../Unread';
|
||||
import * as RoomNotifs from '../../RoomNotifs';
|
||||
import * as FormattingUtils from '../../utils/FormattingUtils';
|
||||
import IndicatorScrollbar from './IndicatorScrollbar';
|
||||
import {Key} from '../../Keyboard';
|
||||
import { Group } from 'matrix-js-sdk';
|
||||
import PropTypes from 'prop-types';
|
||||
import RoomTile from "../views/rooms/RoomTile";
|
||||
import LazyRenderList from "../views/elements/LazyRenderList";
|
||||
import {_t} from "../../languageHandler";
|
||||
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
|
||||
import {toPx} from "../../utils/units";
|
||||
|
||||
// turn this on for drop & drag console debugging galore
|
||||
const debug = false;
|
||||
|
||||
class RoomTileErrorBoundary extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Side effects are not permitted here, so we only update the state so
|
||||
// that the next render shows an error message.
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, { componentStack }) {
|
||||
// Browser consoles are better at formatting output when native errors are passed
|
||||
// in their own `console.error` invocation.
|
||||
console.error(error);
|
||||
console.error(
|
||||
"The above error occured while React was rendering the following components:",
|
||||
componentStack,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (<div className="mx_RoomTile mx_RoomTileError">
|
||||
{this.props.roomId}
|
||||
</div>);
|
||||
} else {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class RoomSubList extends React.PureComponent {
|
||||
static displayName = 'RoomSubList';
|
||||
static debug = debug;
|
||||
|
||||
static propTypes = {
|
||||
list: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
tagName: PropTypes.string,
|
||||
addRoomLabel: PropTypes.string,
|
||||
|
||||
// passed through to RoomTile and used to highlight room with `!` regardless of notifications count
|
||||
isInvite: PropTypes.bool,
|
||||
|
||||
startAsHidden: PropTypes.bool,
|
||||
showSpinner: PropTypes.bool, // true to show a spinner if 0 elements when expanded
|
||||
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
|
||||
onHeaderClick: PropTypes.func,
|
||||
incomingCall: PropTypes.object,
|
||||
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
|
||||
forceExpand: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onHeaderClick: function() {
|
||||
}, // NOP
|
||||
extraTiles: [],
|
||||
isInvite: false,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
return {
|
||||
listLength: props.list.length,
|
||||
scrollTop: props.list.length === state.listLength ? state.scrollTop : 0,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hidden: this.props.startAsHidden || false,
|
||||
// some values to get LazyRenderList starting
|
||||
scrollerHeight: 800,
|
||||
scrollTop: 0,
|
||||
// React 16's getDerivedStateFromProps(props, state) doesn't give the previous props so
|
||||
// we have to store the length of the list here so we can see if it's changed or not...
|
||||
listLength: null,
|
||||
};
|
||||
|
||||
this._header = createRef();
|
||||
this._subList = createRef();
|
||||
this._scroller = createRef();
|
||||
this._headerButton = createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
// The header is collapsible if it is hidden or not stuck
|
||||
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
|
||||
isCollapsibleOnClick() {
|
||||
const stuck = this._header.current.dataset.stuck;
|
||||
if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
switch (payload.action) {
|
||||
case 'on_room_read':
|
||||
// XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched,
|
||||
// but this is no longer true, so we must do it here (and can apply the small
|
||||
// optimisation of checking that we care about the room being read).
|
||||
//
|
||||
// Ultimately we need to transition to a state pushing flow where something
|
||||
// explicitly notifies the components concerned that the notif count for a room
|
||||
// has change (e.g. a Flux store).
|
||||
if (this.props.list.some((r) => r.roomId === payload.roomId)) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'view_room':
|
||||
if (this.state.hidden && !this.props.forceExpand && payload.show_room_tile &&
|
||||
this.props.list.some((r) => r.roomId === payload.room_id)
|
||||
) {
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
if (this.isCollapsibleOnClick()) {
|
||||
// The header isCollapsible, so the click is to be interpreted as collapse and truncation logic
|
||||
const isHidden = !this.state.hidden;
|
||||
this.setState({hidden: isHidden}, () => {
|
||||
this.props.onHeaderClick(isHidden);
|
||||
});
|
||||
} else {
|
||||
// The header is stuck, so the click is to be interpreted as a scroll to the header
|
||||
this.props.onHeaderClick(this.state.hidden, this._header.current.dataset.originalPosition);
|
||||
}
|
||||
};
|
||||
|
||||
onClick = (ev) => {
|
||||
this.toggle();
|
||||
};
|
||||
|
||||
onHeaderKeyDown = (ev) => {
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
// On ARROW_LEFT collapse the room sublist
|
||||
if (!this.state.hidden && !this.props.forceExpand) {
|
||||
this.onClick();
|
||||
}
|
||||
ev.stopPropagation();
|
||||
break;
|
||||
case Key.ARROW_RIGHT: {
|
||||
ev.stopPropagation();
|
||||
if (this.state.hidden && !this.props.forceExpand) {
|
||||
// sublist is collapsed, expand it
|
||||
this.onClick();
|
||||
} else if (!this.props.forceExpand) {
|
||||
// sublist is expanded, go to first room
|
||||
const element = this._subList.current && this._subList.current.querySelector(".mx_RoomTile");
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onKeyDown = (ev) => {
|
||||
switch (ev.key) {
|
||||
// On ARROW_LEFT go to the sublist header
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
this._headerButton.current.focus();
|
||||
break;
|
||||
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
|
||||
case Key.ARROW_RIGHT:
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
onRoomTileClick = (roomId, ev) => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
room_id: roomId,
|
||||
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
||||
});
|
||||
};
|
||||
|
||||
_updateSubListCount = () => {
|
||||
// Force an update by setting the state to the current state
|
||||
// Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate()
|
||||
// method is honoured
|
||||
this.setState(this.state);
|
||||
};
|
||||
|
||||
makeRoomTile = (room) => {
|
||||
return <RoomTileErrorBoundary roomId={room.roomId}><RoomTile
|
||||
room={room}
|
||||
roomSubList={this}
|
||||
tagName={this.props.tagName}
|
||||
key={room.roomId}
|
||||
collapsed={this.props.collapsed || false}
|
||||
unread={Unread.doesRoomHaveUnreadMessages(room)}
|
||||
highlight={this.props.isInvite || RoomNotifs.getUnreadNotificationCount(room, 'highlight') > 0}
|
||||
notificationCount={RoomNotifs.getUnreadNotificationCount(room)}
|
||||
isInvite={this.props.isInvite}
|
||||
refreshSubList={this._updateSubListCount}
|
||||
incomingCall={null}
|
||||
onClick={this.onRoomTileClick}
|
||||
/></RoomTileErrorBoundary>;
|
||||
};
|
||||
|
||||
_onNotifBadgeClick = (e) => {
|
||||
// prevent the roomsublist collapsing
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const room = this.props.list.find(room => RoomNotifs.getRoomHasBadge(room));
|
||||
if (room) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.roomId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onInviteBadgeClick = (e) => {
|
||||
// prevent the roomsublist collapsing
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// switch to first room in sortedList as that'll be the top of the list for the user
|
||||
if (this.props.list && this.props.list.length > 0) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: this.props.list[0].roomId,
|
||||
});
|
||||
} else if (this.props.extraTiles && this.props.extraTiles.length > 0) {
|
||||
// Group Invites are different in that they are all extra tiles and not rooms
|
||||
// XXX: this is a horrible special case because Group Invite sublist is a hack
|
||||
if (this.props.extraTiles[0].props && this.props.extraTiles[0].props.group instanceof Group) {
|
||||
dis.dispatch({
|
||||
action: 'view_group',
|
||||
group_id: this.props.extraTiles[0].props.group.groupId,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
};
|
||||
|
||||
_getHeaderJsx(isCollapsed) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton');
|
||||
const subListNotifications = !this.props.isInvite ?
|
||||
RoomNotifs.aggregateNotificationCount(this.props.list) :
|
||||
{count: 0, highlight: true};
|
||||
const subListNotifCount = subListNotifications.count;
|
||||
const subListNotifHighlight = subListNotifications.highlight;
|
||||
|
||||
// When collapsed, allow a long hover on the header to show user
|
||||
// the full tag name and room count
|
||||
let title;
|
||||
if (this.props.collapsed) {
|
||||
title = this.props.label;
|
||||
}
|
||||
|
||||
let incomingCall;
|
||||
if (this.props.incomingCall) {
|
||||
// We can assume that if we have an incoming call then it is for this list
|
||||
const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
|
||||
incomingCall =
|
||||
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
|
||||
}
|
||||
|
||||
const len = this.props.list.length + this.props.extraTiles.length;
|
||||
let chevron;
|
||||
if (len) {
|
||||
const chevronClasses = classNames({
|
||||
'mx_RoomSubList_chevron': true,
|
||||
'mx_RoomSubList_chevronRight': isCollapsed,
|
||||
'mx_RoomSubList_chevronDown': !isCollapsed,
|
||||
});
|
||||
chevron = (<div className={chevronClasses} />);
|
||||
}
|
||||
|
||||
return <RovingTabIndexWrapper inputRef={this._headerButton}>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
let badge;
|
||||
if (!this.props.collapsed) {
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomSubList_badge': true,
|
||||
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
|
||||
});
|
||||
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||
if (subListNotifCount > 0) {
|
||||
badge = (
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
className={badgeClasses}
|
||||
onClick={this._onNotifBadgeClick}
|
||||
aria-label={_t("Jump to first unread room.")}
|
||||
>
|
||||
<div>
|
||||
{ FormattingUtils.formatCount(subListNotifCount) }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (this.props.isInvite && this.props.list.length) {
|
||||
// no notifications but highlight anyway because this is an invite badge
|
||||
badge = (
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
className={badgeClasses}
|
||||
onClick={this._onInviteBadgeClick}
|
||||
aria-label={_t("Jump to first invite.")}
|
||||
>
|
||||
<div>
|
||||
{ this.props.list.length }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let addRoomButton;
|
||||
if (this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSubList_addRoom"
|
||||
title={this.props.addRoomLabel || _t("Add room")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
tabIndex={tabIndex}
|
||||
inputRef={ref}
|
||||
onClick={this.onClick}
|
||||
className="mx_RoomSubList_label"
|
||||
aria-expanded={!isCollapsed}
|
||||
role="treeitem"
|
||||
aria-level="1"
|
||||
>
|
||||
{ chevron }
|
||||
<span>{this.props.label}</span>
|
||||
{ incomingCall }
|
||||
</AccessibleButton>
|
||||
{ badge }
|
||||
{ addRoomButton }
|
||||
</div>
|
||||
);
|
||||
} }
|
||||
</RovingTabIndexWrapper>;
|
||||
}
|
||||
|
||||
checkOverflow = () => {
|
||||
if (this._scroller.current) {
|
||||
this._scroller.current.checkOverflow();
|
||||
}
|
||||
};
|
||||
|
||||
setHeight = (height) => {
|
||||
if (this._subList.current) {
|
||||
this._subList.current.style.height = toPx(height);
|
||||
}
|
||||
this._updateLazyRenderHeight(height);
|
||||
};
|
||||
|
||||
_updateLazyRenderHeight(height) {
|
||||
this.setState({scrollerHeight: height});
|
||||
}
|
||||
|
||||
_onScroll = () => {
|
||||
this.setState({scrollTop: this._scroller.current.getScrollTop()});
|
||||
};
|
||||
|
||||
_canUseLazyListRendering() {
|
||||
// for now disable lazy rendering as they are already rendered tiles
|
||||
// not rooms like props.list we pass to LazyRenderList
|
||||
return !this.props.extraTiles || !this.props.extraTiles.length;
|
||||
}
|
||||
|
||||
render() {
|
||||
const len = this.props.list.length + this.props.extraTiles.length;
|
||||
const isCollapsed = this.state.hidden && !this.props.forceExpand;
|
||||
|
||||
const subListClasses = classNames({
|
||||
"mx_RoomSubList": true,
|
||||
"mx_RoomSubList_hidden": len && isCollapsed,
|
||||
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
|
||||
});
|
||||
|
||||
let content;
|
||||
if (len) {
|
||||
if (isCollapsed) {
|
||||
// no body
|
||||
} else if (this._canUseLazyListRendering()) {
|
||||
content = (
|
||||
<IndicatorScrollbar ref={this._scroller} className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
|
||||
<LazyRenderList
|
||||
scrollTop={this.state.scrollTop }
|
||||
height={ this.state.scrollerHeight }
|
||||
renderItem={ this.makeRoomTile }
|
||||
itemHeight={34}
|
||||
items={ this.props.list } />
|
||||
</IndicatorScrollbar>
|
||||
);
|
||||
} else {
|
||||
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
|
||||
const tiles = roomTiles.concat(this.props.extraTiles);
|
||||
content = (
|
||||
<IndicatorScrollbar ref={this._scroller} className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
|
||||
{ tiles }
|
||||
</IndicatorScrollbar>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.props.showSpinner && !isCollapsed) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
content = <Loader />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this._subList}
|
||||
className={subListClasses}
|
||||
role="group"
|
||||
aria-label={this.props.label}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{ this._getHeaderJsx(isCollapsed) }
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1380,15 +1380,9 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
onForgetClick: function() {
|
||||
this.context.forget(this.state.room.roomId).then(function() {
|
||||
dis.dispatch({ action: 'view_next_room' });
|
||||
}, function(err) {
|
||||
const errCode = err.errcode || _t("unknown error code");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Failed to forget room %(errCode)s", { errCode: errCode }),
|
||||
});
|
||||
dis.dispatch({
|
||||
action: 'forget_room',
|
||||
room_id: this.state.room.roomId,
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -1819,6 +1813,7 @@ export default createReactClass({
|
|||
);
|
||||
|
||||
const showRoomRecoveryReminder = (
|
||||
this.context.isCryptoEnabled() &&
|
||||
SettingsStore.getValue("showRoomRecoveryReminder") &&
|
||||
this.context.isRoomEncrypted(this.state.room.roomId) &&
|
||||
this.context.getKeyBackupEnabled() === false
|
||||
|
@ -1932,25 +1927,29 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
if (inCall) {
|
||||
let zoomButton; let voiceMuteButton; let videoMuteButton;
|
||||
let zoomButton; let videoMuteButton;
|
||||
|
||||
if (call.type === "video") {
|
||||
zoomButton = (
|
||||
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
|
||||
<TintableSvg src={require("../../../res/img/fullscreen.svg")} width="29" height="22" style={{ marginTop: 1, marginRight: 4 }} />
|
||||
<TintableSvg src={require("../../../res/img/element-icons/call/fullscreen.svg")} width="29" height="22" style={{ marginTop: 1, marginRight: 4 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
videoMuteButton =
|
||||
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
|
||||
<TintableSvg src={call.isLocalVideoMuted() ? require("../../../res/img/video-unmute.svg") : require("../../../res/img/video-mute.svg")}
|
||||
<TintableSvg src={call.isLocalVideoMuted() ?
|
||||
require("../../../res/img/element-icons/call/video-muted.svg") :
|
||||
require("../../../res/img/element-icons/call/video-call.svg")}
|
||||
alt={call.isLocalVideoMuted() ? _t("Click to unmute video") : _t("Click to mute video")}
|
||||
width="31" height="27" />
|
||||
width="" height="27" />
|
||||
</div>;
|
||||
}
|
||||
voiceMuteButton =
|
||||
const voiceMuteButton =
|
||||
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
|
||||
<TintableSvg src={call.isMicrophoneMuted() ? require("../../../res/img/voice-unmute.svg") : require("../../../res/img/voice-mute.svg")}
|
||||
<TintableSvg src={call.isMicrophoneMuted() ?
|
||||
require("../../../res/img/element-icons/call/voice-muted.svg") :
|
||||
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
|
||||
alt={call.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
|
||||
width="21" height="26" />
|
||||
</div>;
|
||||
|
@ -1962,7 +1961,6 @@ export default createReactClass({
|
|||
{ videoMuteButton }
|
||||
{ zoomButton }
|
||||
{ statusBar }
|
||||
<TintableSvg className="mx_RoomView_voipChevron" src={require("../../../res/img/voip-chevron.svg")} width="22" height="17" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -2043,6 +2041,7 @@ export default createReactClass({
|
|||
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
|
||||
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
|
||||
jumpToBottom = (<JumpToBottomButton
|
||||
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
|
||||
numUnreadMessages={this.state.numUnreadMessages}
|
||||
onScrollToBottomClick={this.jumpToLiveTimeline}
|
||||
/>);
|
||||
|
|
|
@ -1,158 +0,0 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TopLeftMenu from '../views/context_menus/TopLeftMenu';
|
||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import * as Avatar from '../../Avatar';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const AVATAR_SIZE = 28;
|
||||
|
||||
export default class TopLeftMenuButton extends React.Component {
|
||||
static propTypes = {
|
||||
collapsed: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
static displayName = 'TopLeftMenuButton';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
menuDisplayed: false,
|
||||
profileInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
async _getProfileInfo() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
const profileInfo = await cli.getProfileInfo(userId);
|
||||
const avatarUrl = Avatar.avatarUrlForUser(
|
||||
{avatarUrl: profileInfo.avatar_url},
|
||||
AVATAR_SIZE, AVATAR_SIZE, "crop");
|
||||
|
||||
return {
|
||||
userId,
|
||||
name: profileInfo.displayname,
|
||||
avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
try {
|
||||
const profileInfo = await this._getProfileInfo();
|
||||
this.setState({profileInfo});
|
||||
} catch (ex) {
|
||||
console.log("could not fetch profile");
|
||||
console.error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
// For accessibility
|
||||
if (payload.action === Action.ToggleUserMenu) {
|
||||
if (this._buttonRef) this._buttonRef.click();
|
||||
}
|
||||
};
|
||||
|
||||
_getDisplayName() {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return _t("Guest");
|
||||
} else if (this.state.profileInfo) {
|
||||
return this.state.profileInfo.name;
|
||||
} else {
|
||||
return MatrixClientPeg.get().getUserId();
|
||||
}
|
||||
}
|
||||
|
||||
openMenu = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.setState({ menuDisplayed: true });
|
||||
};
|
||||
|
||||
closeMenu = () => {
|
||||
this.setState({
|
||||
menuDisplayed: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const cli = MatrixClientPeg.get().getUserId();
|
||||
|
||||
const name = this._getDisplayName();
|
||||
let nameElement;
|
||||
let chevronElement;
|
||||
if (!this.props.collapsed) {
|
||||
nameElement = <div className="mx_TopLeftMenuButton_name">
|
||||
{ name }
|
||||
</div>;
|
||||
chevronElement = <span className="mx_TopLeftMenuButton_chevron" />;
|
||||
}
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.menuDisplayed) {
|
||||
const elementRect = this._buttonRef.getBoundingClientRect();
|
||||
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height}
|
||||
onFinished={this.closeMenu}
|
||||
>
|
||||
<TopLeftMenu displayName={name} userId={cli} onFinished={this.closeMenu} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_TopLeftMenuButton"
|
||||
onClick={this.openMenu}
|
||||
inputRef={(r) => this._buttonRef = r}
|
||||
label={_t("Your profile")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
>
|
||||
<BaseAvatar
|
||||
idName={MatrixClientPeg.get().getUserId()}
|
||||
name={name}
|
||||
url={this.state.profileInfo && this.state.profileInfo.avatarUrl}
|
||||
width={AVATAR_SIZE}
|
||||
height={AVATAR_SIZE}
|
||||
resizeMethod="crop"
|
||||
/>
|
||||
{ nameElement }
|
||||
{ chevronElement }
|
||||
</ContextMenuButton>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
|
@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import React, { createRef } from "react";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { createRef } from "react";
|
||||
import { _t } from "../../languageHandler";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
|
||||
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||
|
@ -30,23 +29,39 @@ import LogoutDialog from "../views/dialogs/LogoutDialog";
|
|||
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||
import {getCustomTheme} from "../../theme";
|
||||
import {getHostingLink} from "../../utils/HostingLink";
|
||||
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import {getHomePageUrl} from "../../utils/pages";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import classNames from "classnames";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
menuDisplayed: boolean;
|
||||
contextMenuPosition: PartialDOMRect;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
interface IMenuButtonProps {
|
||||
iconClassName: string;
|
||||
label: string;
|
||||
onClick(ev: ButtonEvent);
|
||||
}
|
||||
|
||||
const MenuButton: React.FC<IMenuButtonProps> = ({iconClassName, label, onClick}) => {
|
||||
return <MenuItem label={label} onClick={onClick}>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{label}</span>
|
||||
</MenuItem>;
|
||||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
|
@ -56,7 +71,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
menuDisplayed: false,
|
||||
contextMenuPosition: null,
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
};
|
||||
|
||||
|
@ -99,23 +114,41 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
private onAction = (ev: ActionPayload) => {
|
||||
if (ev.action !== Action.ToggleUserMenu) return; // not interested
|
||||
|
||||
// For accessibility
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
if (this.state.contextMenuPosition) {
|
||||
this.setState({contextMenuPosition: null});
|
||||
} else {
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
private onOpenMenuClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: true});
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({contextMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onCloseMenu = (ev: InputEvent) => {
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: false});
|
||||
this.setState({
|
||||
contextMenuPosition: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
width: 20,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = () => {
|
||||
private onCloseMenu = () => {
|
||||
this.setState({contextMenuPosition: null});
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// Disable system theme matching if the user hits this button
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||
|
||||
|
@ -129,14 +162,15 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
|
||||
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onShowArchived = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// TODO: Archived room view (deferred)
|
||||
// TODO: Archived room view: https://github.com/vector-im/riot-web/issues/14038
|
||||
// Note: You'll need to uncomment the button too.
|
||||
console.log("TODO: Show archived rooms");
|
||||
};
|
||||
|
||||
|
@ -145,7 +179,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = (ev: ButtonEvent) => {
|
||||
|
@ -153,7 +187,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onHomeClick = (ev: ButtonEvent) => {
|
||||
|
@ -164,7 +198,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private renderContextMenu = (): React.ReactNode => {
|
||||
if (!this.state.menuDisplayed) return null;
|
||||
if (!this.state.contextMenuPosition) return null;
|
||||
|
||||
let hostingLink;
|
||||
const signupLink = getHostingLink("user-context-menu");
|
||||
|
@ -191,21 +225,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
let homeButton = null;
|
||||
if (this.hasHomePage) {
|
||||
homeButton = (
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onHomeClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
|
||||
<span>{_t("Home")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconHome"
|
||||
label={_t("Home")}
|
||||
onClick={this.onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const elementRect = this.buttonRef.current.getBoundingClientRect();
|
||||
return (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.width + elementRect.left}
|
||||
top={elementRect.top + elementRect.height}
|
||||
chevronFace={ChevronFace.None}
|
||||
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
|
||||
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
|
||||
|
@ -218,63 +251,53 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
{MatrixClientPeg.get().getUserId()}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
<AccessibleTooltipButton
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
|
||||
>
|
||||
<img
|
||||
src={require("../../../res/img/feather-customised/sun.svg")}
|
||||
src={require("../../../res/img/element-icons/roomlist/dark-light-mode.svg")}
|
||||
alt={_t("Switch theme")}
|
||||
width={16}
|
||||
/>
|
||||
</div>
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{hostingLink}
|
||||
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
|
||||
<ul>
|
||||
{homeButton}
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
|
||||
<span>{_t("Notification settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
|
||||
<span>{_t("Security & privacy")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
|
||||
<span>{_t("All settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onShowArchived}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
|
||||
<span>{_t("Archived rooms")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onProvideFeedback}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
|
||||
<span>{_t("Feedback")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
{homeButton}
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("Security & privacy")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{/* <MenuButton
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/> */}
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("Feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li className="mx_UserMenu_contextMenu_redRow">
|
||||
<AccessibleButton onClick={this.onSignOutClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
|
||||
<span>{_t("Sign out")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
@ -283,6 +306,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
|
||||
public render() {
|
||||
const avatarSize = 32; // should match border-radius of the avatar
|
||||
const {body} = document;
|
||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
body.style.setProperty("--avatar-url", `url('${avatarUrl}')`);
|
||||
|
||||
let name = <span className="mx_UserMenu_userName">{OwnProfileStore.instance.displayName}</span>;
|
||||
let buttons = (
|
||||
|
@ -306,8 +332,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
className={classes}
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.buttonRef}
|
||||
label={_t("Account settings")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
label={_t("User menu")}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<div className="mx_UserMenu_row">
|
||||
<span className="mx_UserMenu_userAvatarContainer">
|
||||
|
@ -324,8 +351,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
{name}
|
||||
{buttons}
|
||||
</div>
|
||||
{this.renderContextMenu()}
|
||||
</ContextMenuButton>
|
||||
{this.renderContextMenu()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import {
|
||||
|
@ -131,6 +132,8 @@ export default class SetupEncryptionBody extends React.Component {
|
|||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
|
@ -138,17 +141,18 @@ export default class SetupEncryptionBody extends React.Component {
|
|||
"granting it access to encrypted messages.",
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"This requires the latest Riot on your other devices:",
|
||||
"This requires the latest %(brand)s on your other devices:",
|
||||
{ brand },
|
||||
)}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_clients">
|
||||
<div className="mx_CompleteSecurity_clients_desktop">
|
||||
<div>Riot Web</div>
|
||||
<div>Riot Desktop</div>
|
||||
<div>{_t("%(brand)s Web", { brand })}</div>
|
||||
<div>{_t("%(brand)s Desktop", { brand })}</div>
|
||||
</div>
|
||||
<div className="mx_CompleteSecurity_clients_mobile">
|
||||
<div>Riot iOS</div>
|
||||
<div>Riot X for Android</div>
|
||||
<div>{_t("%(brand)s iOS", { brand })}</div>
|
||||
<div>{_t("%(brand)s X for Android", { brand })}</div>
|
||||
</div>
|
||||
<p>{_t("or another cross-signing capable Matrix client")}</p>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
|
@ -18,11 +18,13 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'CustomServerDialog',
|
||||
|
||||
render: function() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
return (
|
||||
<div className="mx_ErrorDialog">
|
||||
<div className="mx_Dialog_title">
|
||||
|
@ -32,8 +34,9 @@ export default createReactClass({
|
|||
<p>{_t(
|
||||
"You can use the custom server options to sign into other " +
|
||||
"Matrix servers by specifying a different homeserver URL. This " +
|
||||
"allows you to use this app with an existing Matrix account on a " +
|
||||
"allows you to use %(brand)s with an existing Matrix account on a " +
|
||||
"different homeserver.",
|
||||
{ brand },
|
||||
)}</p>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
|
|
|
@ -23,8 +23,8 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
|||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import ServerConfig from "./ServerConfig";
|
||||
|
||||
const MODULAR_URL = 'https://modular.im/services/matrix-hosting-riot' +
|
||||
'?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
const MODULAR_URL = 'https://element.io/matrix-services' +
|
||||
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
|
||||
|
||||
// TODO: TravisR - Can this extend ServerConfig for most things?
|
||||
|
||||
|
@ -95,10 +95,10 @@ export default class ModularServerConfig extends ServerConfig {
|
|||
|
||||
return (
|
||||
<div className="mx_ServerConfig">
|
||||
<h3>{_t("Your Modular server")}</h3>
|
||||
<h3>{_t("Your server")}</h3>
|
||||
{_t(
|
||||
"Enter the location of your Modular homeserver. It may use your own " +
|
||||
"domain name or be a subdomain of <a>modular.im</a>.",
|
||||
"Enter the location of your Element Matrix Services homeserver. It may use your own " +
|
||||
"domain name or be a subdomain of <a>element.io</a>.",
|
||||
{}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
|
|
|
@ -22,8 +22,8 @@ import classnames from 'classnames';
|
|||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import {makeType} from "../../../utils/TypeUtils";
|
||||
|
||||
const MODULAR_URL = 'https://modular.im/services/matrix-hosting-riot' +
|
||||
'?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
const MODULAR_URL = 'https://element.io/matrix-services' +
|
||||
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
|
||||
|
||||
export const FREE = 'Free';
|
||||
export const PREMIUM = 'Premium';
|
||||
|
@ -45,7 +45,7 @@ export const TYPES = {
|
|||
PREMIUM: {
|
||||
id: PREMIUM,
|
||||
label: () => _t('Premium'),
|
||||
logo: () => <img src={require('../../../../res/img/modular-bw-logo.svg')} />,
|
||||
logo: () => <img src={require('../../../../res/img/ems-logo.svg')} height={16} />,
|
||||
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import * as AvatarLogic from '../../../Avatar';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
|
@ -26,9 +26,25 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
|||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
import {toPx} from "../../../utils/units";
|
||||
|
||||
const useImageUrl = ({url, urls}) => {
|
||||
const [imageUrls, setUrls] = useState([]);
|
||||
const [urlsIndex, setIndex] = useState();
|
||||
interface IProps {
|
||||
name: string; // The name (first initial used as default)
|
||||
idName?: string; // ID for generating hash colours
|
||||
title?: string; // onHover title text
|
||||
url?: string; // highest priority of them all, shortcut to set in urls[0]
|
||||
urls?: string[]; // [highest_priority, ... , lowest_priority]
|
||||
width?: number;
|
||||
height?: number;
|
||||
// XXX: resizeMethod not actually used.
|
||||
resizeMethod?: string;
|
||||
defaultToInitialLetter?: boolean; // true to add default url
|
||||
onClick?: React.MouseEventHandler;
|
||||
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const useImageUrl = ({url, urls}): [string, () => void] => {
|
||||
const [imageUrls, setUrls] = useState<string[]>([]);
|
||||
const [urlsIndex, setIndex] = useState<number>();
|
||||
|
||||
const onError = useCallback(() => {
|
||||
setIndex(i => i + 1); // try the next one
|
||||
|
@ -70,19 +86,20 @@ const useImageUrl = ({url, urls}) => {
|
|||
return [imageUrl, onError];
|
||||
};
|
||||
|
||||
const BaseAvatar = (props) => {
|
||||
const BaseAvatar = (props: IProps) => {
|
||||
const {
|
||||
name,
|
||||
idName,
|
||||
title,
|
||||
url,
|
||||
urls,
|
||||
width=40,
|
||||
height=40,
|
||||
resizeMethod="crop", // eslint-disable-line no-unused-vars
|
||||
defaultToInitialLetter=true,
|
||||
width = 40,
|
||||
height = 40,
|
||||
resizeMethod = "crop", // eslint-disable-line no-unused-vars
|
||||
defaultToInitialLetter = true,
|
||||
onClick,
|
||||
inputRef,
|
||||
className,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
|
@ -117,12 +134,12 @@ const BaseAvatar = (props) => {
|
|||
aria-hidden="true" />
|
||||
);
|
||||
|
||||
if (onClick != null) {
|
||||
if (onClick !== null) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...otherProps}
|
||||
element="span"
|
||||
className="mx_BaseAvatar"
|
||||
className={classNames("mx_BaseAvatar", className)}
|
||||
onClick={onClick}
|
||||
inputRef={inputRef}
|
||||
>
|
||||
|
@ -132,7 +149,12 @@ const BaseAvatar = (props) => {
|
|||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="mx_BaseAvatar" ref={inputRef} {...otherProps}>
|
||||
<span
|
||||
className={classNames("mx_BaseAvatar", className)}
|
||||
ref={inputRef}
|
||||
{...otherProps}
|
||||
role="presentation"
|
||||
>
|
||||
{ textNode }
|
||||
{ imgNode }
|
||||
</span>
|
||||
|
@ -140,10 +162,10 @@ const BaseAvatar = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (onClick != null) {
|
||||
if (onClick !== null) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_BaseAvatar mx_BaseAvatar_image"
|
||||
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
|
||||
element='img'
|
||||
src={imageUrl}
|
||||
onClick={onClick}
|
||||
|
@ -159,7 +181,7 @@ const BaseAvatar = (props) => {
|
|||
} else {
|
||||
return (
|
||||
<img
|
||||
className="mx_BaseAvatar mx_BaseAvatar_image"
|
||||
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
|
||||
src={imageUrl}
|
||||
onError={onError}
|
||||
style={{
|
||||
|
@ -173,26 +195,5 @@ const BaseAvatar = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
BaseAvatar.displayName = "BaseAvatar";
|
||||
|
||||
BaseAvatar.propTypes = {
|
||||
name: PropTypes.string.isRequired, // The name (first initial used as default)
|
||||
idName: PropTypes.string, // ID for generating hash colours
|
||||
title: PropTypes.string, // onHover title text
|
||||
url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
|
||||
urls: PropTypes.array, // [highest_priority, ... , lowest_priority]
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
// XXX resizeMethod not actually used.
|
||||
resizeMethod: PropTypes.string,
|
||||
defaultToInitialLetter: PropTypes.bool, // true to add default url
|
||||
onClick: PropTypes.func,
|
||||
inputRef: PropTypes.oneOfType([
|
||||
// Either a function
|
||||
PropTypes.func,
|
||||
// Or the instance of a DOM native element
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
};
|
||||
|
||||
export default BaseAvatar;
|
||||
export type BaseAvatarType = React.FC<IProps>;
|
73
src/components/views/avatars/DecoratedRoomAvatar.tsx
Normal file
73
src/components/views/avatars/DecoratedRoomAvatar.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
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 React from 'react';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { TagID } from '../../../stores/room-list/models';
|
||||
import RoomAvatar from "./RoomAvatar";
|
||||
import RoomTileIcon from "../rooms/RoomTileIcon";
|
||||
import NotificationBadge from '../rooms/NotificationBadge';
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
avatarSize: number;
|
||||
tag: TagID;
|
||||
displayBadge?: boolean;
|
||||
forceCount?: boolean;
|
||||
oobData?: object;
|
||||
viewAvatarOnClick?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
notificationState?: NotificationState;
|
||||
}
|
||||
|
||||
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
|
||||
};
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let badge: React.ReactNode;
|
||||
if (this.props.displayBadge) {
|
||||
badge = <NotificationBadge
|
||||
notification={this.state.notificationState}
|
||||
forceCount={this.props.forceCount}
|
||||
roomId={this.props.room.roomId}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div className="mx_DecoratedRoomAvatar">
|
||||
<RoomAvatar
|
||||
room={this.props.room}
|
||||
width={this.props.avatarSize}
|
||||
height={this.props.avatarSize}
|
||||
oobData={this.props.oobData}
|
||||
viewAvatarOnClick={this.props.viewAvatarOnClick}
|
||||
/>
|
||||
<RoomTileIcon room={this.props.room} />
|
||||
{badge}
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -15,43 +15,36 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import BaseAvatar from './BaseAvatar';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'GroupAvatar',
|
||||
export interface IProps {
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
groupAvatarUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeMethod?: string;
|
||||
onClick?: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
groupId: PropTypes.string,
|
||||
groupName: PropTypes.string,
|
||||
groupAvatarUrl: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
},
|
||||
export default class GroupAvatar extends React.Component<IProps> {
|
||||
public static defaultProps = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 36,
|
||||
height: 36,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
},
|
||||
|
||||
getGroupAvatarUrl: function() {
|
||||
getGroupAvatarUrl() {
|
||||
return MatrixClientPeg.get().mxcUrlToHttp(
|
||||
this.props.groupAvatarUrl,
|
||||
this.props.width,
|
||||
this.props.height,
|
||||
this.props.resizeMethod,
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
render() {
|
||||
// extract the props we use from props so we can pass any others through
|
||||
// should consider adding this as a global rule in js-sdk?
|
||||
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
|
||||
|
@ -65,5 +58,5 @@ export default createReactClass({
|
|||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -16,48 +16,50 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from "../../../index";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import BaseAvatar from "./BaseAvatar";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MemberAvatar',
|
||||
interface IProps {
|
||||
// TODO: replace with correct type
|
||||
member: any;
|
||||
fallbackUserId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
resizeMethod: string;
|
||||
// The onClick to give the avatar
|
||||
onClick: React.MouseEventHandler;
|
||||
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
|
||||
viewUserOnClick: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
member: PropTypes.object,
|
||||
fallbackUserId: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
// The onClick to give the avatar
|
||||
onClick: PropTypes.func,
|
||||
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
|
||||
viewUserOnClick: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
},
|
||||
interface IState {
|
||||
name: string;
|
||||
title: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
viewUserOnClick: false,
|
||||
};
|
||||
},
|
||||
export default class MemberAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
viewUserOnClick: false,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return this._getState(this.props);
|
||||
},
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(nextProps) {
|
||||
this.setState(this._getState(nextProps));
|
||||
},
|
||||
this.state = MemberAvatar.getState(props);
|
||||
}
|
||||
|
||||
_getState: function(props) {
|
||||
public static getDerivedStateFromProps(nextProps: IProps): IState {
|
||||
return MemberAvatar.getState(nextProps);
|
||||
}
|
||||
|
||||
private static getState(props: IProps): IState {
|
||||
if (props.member && props.member.name) {
|
||||
return {
|
||||
name: props.member.name,
|
||||
|
@ -79,11 +81,9 @@ export default createReactClass({
|
|||
} else {
|
||||
console.error("MemberAvatar called somehow with null member or fallbackUserId");
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
}
|
||||
|
||||
render() {
|
||||
let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props;
|
||||
const userId = member ? member.userId : fallbackUserId;
|
||||
|
||||
|
@ -100,5 +100,5 @@ export default createReactClass({
|
|||
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
|
||||
idName={userId} url={this.state.imageUrl} onClick={onClick} />
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
|
@ -16,20 +15,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomDropTarget',
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
render: function() {
|
||||
return (
|
||||
<div className="mx_RoomDropTarget_container">
|
||||
<div className="mx_RoomDropTarget">
|
||||
<div className="mx_RoomDropTarget_label">
|
||||
{ this.props.label }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
const PulsedAvatar: React.FC<IProps> = (props) => {
|
||||
return <div className="mx_PulsedAvatar">
|
||||
{props.children}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default PulsedAvatar;
|
|
@ -13,90 +13,96 @@ 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 from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import React from 'react';
|
||||
import Room from 'matrix-js-sdk/src/models/room';
|
||||
import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
|
||||
|
||||
import BaseAvatar from './BaseAvatar';
|
||||
import ImageView from '../elements/ImageView';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from "../../../index";
|
||||
import * as Avatar from '../../../Avatar';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomAvatar',
|
||||
|
||||
interface IProps {
|
||||
// Room may be left unset here, but if it is,
|
||||
// oobData.avatarUrl should be set (else there
|
||||
// would be nowhere to get the avatar from)
|
||||
propTypes: {
|
||||
room: PropTypes.object,
|
||||
oobData: PropTypes.object,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
viewAvatarOnClick: PropTypes.bool,
|
||||
},
|
||||
room?: Room;
|
||||
// TODO: type when js-sdk has types
|
||||
oobData?: any;
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeMethod?: string;
|
||||
viewAvatarOnClick?: boolean;
|
||||
}
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 36,
|
||||
height: 36,
|
||||
resizeMethod: 'crop',
|
||||
oobData: {},
|
||||
interface IState {
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export default class RoomAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
resizeMethod: 'crop',
|
||||
oobData: {},
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
urls: RoomAvatar.getImageUrls(this.props),
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
urls: this.getImageUrls(this.props),
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
public componentDidMount() {
|
||||
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
public componentWillUnmount() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(newProps) {
|
||||
this.setState({
|
||||
urls: this.getImageUrls(newProps),
|
||||
});
|
||||
},
|
||||
public static getDerivedStateFromProps(nextProps: IProps): IState {
|
||||
return {
|
||||
urls: RoomAvatar.getImageUrls(nextProps),
|
||||
};
|
||||
}
|
||||
|
||||
onRoomStateEvents: function(ev) {
|
||||
// TODO: type when js-sdk has types
|
||||
private onRoomStateEvents = (ev: any) => {
|
||||
if (!this.props.room ||
|
||||
ev.getRoomId() !== this.props.room.roomId ||
|
||||
ev.getType() !== 'm.room.avatar'
|
||||
) return;
|
||||
|
||||
this.setState({
|
||||
urls: this.getImageUrls(this.props),
|
||||
urls: RoomAvatar.getImageUrls(this.props),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
getImageUrls: function(props) {
|
||||
private static getImageUrls(props: IProps): string[] {
|
||||
return [
|
||||
getHttpUriForMxc(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
// Default props don't play nicely with getDerivedStateFromProps
|
||||
//props.oobData !== undefined ? props.oobData.avatarUrl : {},
|
||||
props.oobData.avatarUrl,
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.resizeMethod,
|
||||
), // highest priority
|
||||
this.getRoomAvatarUrl(props),
|
||||
RoomAvatar.getRoomAvatarUrl(props),
|
||||
].filter(function(url) {
|
||||
return (url != null && url != "");
|
||||
return (url !== null && url !== "");
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
getRoomAvatarUrl: function(props) {
|
||||
private static getRoomAvatarUrl(props: IProps): string {
|
||||
if (!props.room) return null;
|
||||
|
||||
return Avatar.avatarUrlForRoom(
|
||||
|
@ -105,35 +111,32 @@ export default createReactClass({
|
|||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.resizeMethod,
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
onRoomAvatarClick: function() {
|
||||
private onRoomAvatarClick = () => {
|
||||
const avatarUrl = this.props.room.getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
null, null, null, false);
|
||||
const ImageView = sdk.getComponent("elements.ImageView");
|
||||
const params = {
|
||||
src: avatarUrl,
|
||||
name: this.props.room.name,
|
||||
};
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
|
||||
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
|
||||
public render() {
|
||||
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
|
||||
|
||||
const roomName = room ? room.name : oobData.name;
|
||||
|
||||
return (
|
||||
<BaseAvatar {...otherProps} name={roomName}
|
||||
<BaseAvatar {...otherProps}
|
||||
name={roomName}
|
||||
idName={room ? room.roomId : null}
|
||||
urls={this.state.urls}
|
||||
onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null}
|
||||
disabled={!this.state.urls[0]} />
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@ import PropTypes from 'prop-types';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import TagOrderActions from '../../../actions/TagOrderActions';
|
||||
import * as sdk from '../../../index';
|
||||
import {MenuItem} from "../../structures/ContextMenu";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
|
@ -54,21 +53,12 @@ export default class TagTileContextMenu extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
return <div>
|
||||
<MenuItem className="mx_TagTileContextMenu_item" onClick={this._onViewCommunityClick}>
|
||||
<TintableSvg
|
||||
className="mx_TagTileContextMenu_item_icon"
|
||||
src={require("../../../../res/img/icons-groups.svg")}
|
||||
width="15"
|
||||
height="15"
|
||||
/>
|
||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
|
||||
{ _t('View Community') }
|
||||
</MenuItem>
|
||||
<hr className="mx_TagTileContextMenu_separator" role="separator" />
|
||||
<MenuItem className="mx_TagTileContextMenu_item" onClick={this._onRemoveClick}>
|
||||
<img className="mx_TagTileContextMenu_item_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" alt="" />
|
||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
|
||||
{ _t('Hide') }
|
||||
</MenuItem>
|
||||
</div>;
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'CreateRoomButton',
|
||||
propTypes: {
|
||||
onCreateRoom: PropTypes.func,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onCreateRoom: function() {},
|
||||
};
|
||||
},
|
||||
|
||||
onClick: function() {
|
||||
this.props.onCreateRoom();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
return (
|
||||
<button className="mx_CreateRoomButton" onClick={this.onClick}>{ _t("Create Room") }</button>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -75,8 +75,12 @@ export default createReactClass({
|
|||
// If provided, this is used to add a aria-describedby attribute
|
||||
contentId: PropTypes.string,
|
||||
|
||||
// optional additional class for the title element
|
||||
titleClass: PropTypes.string,
|
||||
// optional additional class for the title element (basically anything that can be passed to classnames)
|
||||
titleClass: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.object,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
]),
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,9 +19,12 @@ import React from 'react';
|
|||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
export default (props) => {
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
const _onLogoutClicked = () => {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Logout e2e db too new', '', QuestionDialog, {
|
||||
|
@ -28,7 +32,8 @@ export default (props) => {
|
|||
description: _t(
|
||||
"To avoid losing your chat history, you must export your room keys " +
|
||||
"before logging out. You will need to go back to the newer version of " +
|
||||
"Riot to do this",
|
||||
"%(brand)s to do this",
|
||||
{ brand },
|
||||
),
|
||||
button: _t("Sign out"),
|
||||
focus: false,
|
||||
|
@ -42,9 +47,12 @@ export default (props) => {
|
|||
};
|
||||
|
||||
const description =
|
||||
_t("You've previously used a newer version of Riot with this session. " +
|
||||
_t(
|
||||
"You've previously used a newer version of %(brand)s with this session. " +
|
||||
"To use this version again with end to end encryption, you will " +
|
||||
"need to sign out and back in again.");
|
||||
"need to sign out and back in again.",
|
||||
{ brand },
|
||||
);
|
||||
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
|
|
@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
|
|||
import * as sdk from '../../../index';
|
||||
import SyntaxHighlight from '../elements/SyntaxHighlight';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { Room } from "matrix-js-sdk";
|
||||
import { Room, MatrixEvent } from "matrix-js-sdk";
|
||||
import Field from "../elements/Field";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
|
@ -327,6 +327,8 @@ class RoomStateExplorer extends React.PureComponent {
|
|||
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
roomStateEvents: Map<string, Map<string, MatrixEvent>>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -412,30 +414,26 @@ class RoomStateExplorer extends React.PureComponent {
|
|||
if (this.state.eventType === null) {
|
||||
list = <FilteredList query={this.state.queryEventType} onChange={this.onQueryEventType}>
|
||||
{
|
||||
Object.keys(this.roomStateEvents).map((evType) => {
|
||||
const stateGroup = this.roomStateEvents[evType];
|
||||
const stateKeys = Object.keys(stateGroup);
|
||||
|
||||
Array.from(this.roomStateEvents.entries()).map(([eventType, allStateKeys]) => {
|
||||
let onClickFn;
|
||||
if (stateKeys.length === 1 && stateKeys[0] === '') {
|
||||
onClickFn = this.onViewSourceClick(stateGroup[stateKeys[0]]);
|
||||
if (allStateKeys.size === 1 && allStateKeys.has("")) {
|
||||
onClickFn = this.onViewSourceClick(allStateKeys.get(""));
|
||||
} else {
|
||||
onClickFn = this.browseEventType(evType);
|
||||
onClickFn = this.browseEventType(eventType);
|
||||
}
|
||||
|
||||
return <button className={classes} key={evType} onClick={onClickFn}>
|
||||
{ evType }
|
||||
return <button className={classes} key={eventType} onClick={onClickFn}>
|
||||
{eventType}
|
||||
</button>;
|
||||
})
|
||||
}
|
||||
</FilteredList>;
|
||||
} else {
|
||||
const stateGroup = this.roomStateEvents[this.state.eventType];
|
||||
const stateGroup = this.roomStateEvents.get(this.state.eventType);
|
||||
|
||||
list = <FilteredList query={this.state.queryStateKey} onChange={this.onQueryStateKey}>
|
||||
{
|
||||
Object.keys(stateGroup).map((stateKey) => {
|
||||
const ev = stateGroup[stateKey];
|
||||
Array.from(stateGroup.entries()).map(([stateKey, ev]) => {
|
||||
return <button className={classes} key={stateKey} onClick={this.onViewSourceClick(ev)}>
|
||||
{ stateKey }
|
||||
</button>;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import * as sdk from "../../../index";
|
||||
|
||||
export default class IntegrationsImpossibleDialog extends React.Component {
|
||||
|
@ -29,6 +30,7 @@ export default class IntegrationsImpossibleDialog extends React.Component {
|
|||
};
|
||||
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
|
@ -39,8 +41,9 @@ export default class IntegrationsImpossibleDialog extends React.Component {
|
|||
<div className='mx_IntegrationsImpossibleDialog_content'>
|
||||
<p>
|
||||
{_t(
|
||||
"Your Riot doesn't allow you to use an Integration Manager to do this. " +
|
||||
"Your %(brand)s doesn't allow you to use an Integration Manager to do this. " +
|
||||
"Please contact an admin.",
|
||||
{ brand },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -35,8 +35,8 @@ import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../
|
|||
import {inviteMultipleToRoom} from "../../../RoomInvite";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
|
||||
export const KIND_DM = "dm";
|
||||
export const KIND_INVITE = "invite";
|
||||
|
@ -346,8 +346,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
|
||||
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
|
||||
const taggedRooms = RoomListStoreTempProxy.getRoomLists();
|
||||
const dmTaggedRooms = taggedRooms[DefaultTagID.DM];
|
||||
const dmTaggedRooms = RoomListStore.instance.orderedLists[DefaultTagID.DM];
|
||||
const myUserId = MatrixClientPeg.get().getUserId();
|
||||
for (const dmRoom of dmTaggedRooms) {
|
||||
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React, {useState, useCallback, useRef} from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default function KeySignatureUploadFailedDialog({
|
||||
failures,
|
||||
|
@ -65,9 +66,10 @@ export default function KeySignatureUploadFailedDialog({
|
|||
let body;
|
||||
if (!success && !cancelled && continuation && retry > 0) {
|
||||
const reason = causes.get(source) || defaultCause;
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
body = (<div>
|
||||
<p>{_t("Riot encountered an error during upload of:")}</p>
|
||||
<p>{_t("%(brand)s encountered an error during upload of:", { brand })}</p>
|
||||
<p>{reason}</p>
|
||||
{retrying && <Spinner />}
|
||||
<pre>{JSON.stringify(failures, null, 2)}</pre>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,17 +18,28 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default (props) => {
|
||||
const description1 =
|
||||
_t("You've previously used Riot on %(host)s with lazy loading of members enabled. " +
|
||||
"In this version lazy loading is disabled. " +
|
||||
"As the local cache is not compatible between these two settings, " +
|
||||
"Riot needs to resync your account.",
|
||||
{host: props.host});
|
||||
const description2 = _t("If the other version of Riot is still open in another tab, " +
|
||||
"please close it as using Riot on the same host with both " +
|
||||
"lazy loading enabled and disabled simultaneously will cause issues.");
|
||||
const brand = SdkConfig.get().brand;
|
||||
const description1 = _t(
|
||||
"You've previously used %(brand)s on %(host)s with lazy loading of members enabled. " +
|
||||
"In this version lazy loading is disabled. " +
|
||||
"As the local cache is not compatible between these two settings, " +
|
||||
"%(brand)s needs to resync your account.",
|
||||
{
|
||||
brand,
|
||||
host: props.host,
|
||||
},
|
||||
);
|
||||
const description2 = _t(
|
||||
"If the other version of %(brand)s is still open in another tab, " +
|
||||
"please close it as using %(brand)s on the same host with both " +
|
||||
"lazy loading enabled and disabled simultaneously will cause issues.",
|
||||
{
|
||||
brand,
|
||||
},
|
||||
);
|
||||
|
||||
return (<QuestionDialog
|
||||
hasCancelButton={false}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,15 +18,21 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default (props) => {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const description =
|
||||
_t("Riot now uses 3-5x less memory, by only loading information about other users"
|
||||
+ " when needed. Please wait whilst we resynchronise with the server!");
|
||||
_t(
|
||||
"%(brand)s now uses 3-5x less memory, by only loading information " +
|
||||
"about other users when needed. Please wait whilst we resynchronise " +
|
||||
"with the server!",
|
||||
{ brand },
|
||||
);
|
||||
|
||||
return (<QuestionDialog
|
||||
hasCancelButton={false}
|
||||
title={_t("Updating Riot")}
|
||||
title={_t("Updating %(brand)s", { brand })}
|
||||
description={<div>{description}</div>}
|
||||
button={_t("OK")}
|
||||
onFinished={props.onFinished}
|
||||
|
|
116
src/components/views/dialogs/RebrandDialog.tsx
Normal file
116
src/components/views/dialogs/RebrandDialog.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
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 * as PropTypes from 'prop-types';
|
||||
import BaseDialog from './BaseDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import DialogButtons from '../elements/DialogButtons';
|
||||
|
||||
export enum RebrandDialogKind {
|
||||
NAG,
|
||||
ONE_TIME,
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
onFinished: (bool) => void;
|
||||
kind: RebrandDialogKind;
|
||||
targetUrl?: string;
|
||||
}
|
||||
|
||||
export default class RebrandDialog extends React.PureComponent<IProps> {
|
||||
private onDoneClick = () => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
private onGoToElementClick = () => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
private onRemindMeLaterClick = () => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
private getPrettyTargetUrl() {
|
||||
const u = new URL(this.props.targetUrl);
|
||||
let ret = u.host;
|
||||
if (u.pathname !== '/') ret += u.pathname;
|
||||
return ret;
|
||||
}
|
||||
|
||||
getBodyText() {
|
||||
if (this.props.kind === RebrandDialogKind.NAG) {
|
||||
return _t(
|
||||
"Use your account to sign in to the latest version of the app at <a />", {},
|
||||
{
|
||||
a: sub => <a href={this.props.targetUrl} rel="noopener noreferrer" target="_blank">{this.getPrettyTargetUrl()}</a>,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return _t(
|
||||
"You’re already signed in and good to go here, but you can also grab the latest " +
|
||||
"versions of the app on all platforms at <a>element.io/get-started</a>.", {},
|
||||
{
|
||||
a: sub => <a href="https://element.io/get-started" rel="noopener noreferrer" target="_blank">{sub}</a>,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDialogButtons() {
|
||||
if (this.props.kind === RebrandDialogKind.NAG) {
|
||||
return <DialogButtons primaryButton={_t("Go to Element")}
|
||||
primaryButtonClass='primary'
|
||||
onPrimaryButtonClick={this.onGoToElementClick}
|
||||
hasCancel={true}
|
||||
cancelButton={"Remind me later"}
|
||||
onCancel={this.onRemindMeLaterClick}
|
||||
focus={true}
|
||||
/>;
|
||||
} else {
|
||||
return <DialogButtons primaryButton={_t("Done")}
|
||||
primaryButtonClass='primary'
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this.onDoneClick}
|
||||
focus={true}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <BaseDialog title={_t("We’re excited to announce Riot is now Element!")}
|
||||
className='mx_RebrandDialog'
|
||||
contentId='mx_Dialog_content'
|
||||
onFinished={this.props.onFinished}
|
||||
hasCancel={false}
|
||||
>
|
||||
<div className="mx_RebrandDialog_body">{this.getBodyText()}</div>
|
||||
<div className="mx_RebrandDialog_logoContainer">
|
||||
<img className="mx_RebrandDialog_logo" src={require("../../../../res/img/riot-logo.svg")} alt="Riot Logo" />
|
||||
<span className="mx_RebrandDialog_chevron" />
|
||||
<img className="mx_RebrandDialog_logo" src={require("../../../../res/img/element-logo.svg")} alt="Element Logo" />
|
||||
</div>
|
||||
<div>
|
||||
{_t(
|
||||
"Learn more at <a>element.io/previously-riot</a>", {}, {
|
||||
a: sub => <a href="https://element.io/previously-riot" rel="noopener noreferrer" target="_blank">{sub}</a>,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
{this.getDialogButtons()}
|
||||
</BaseDialog>;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import * as sdk from "../../../index";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
|
@ -63,6 +64,7 @@ export default class RoomUpgradeWarningDialog extends React.Component {
|
|||
};
|
||||
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
|
@ -96,8 +98,11 @@ export default class RoomUpgradeWarningDialog extends React.Component {
|
|||
<p>
|
||||
{_t(
|
||||
"This usually only affects how the room is processed on the server. If you're " +
|
||||
"having problems with your Riot, please <a>report a bug</a>.",
|
||||
{}, {
|
||||
"having problems with your %(brand)s, please <a>report a bug</a>.",
|
||||
{
|
||||
brand,
|
||||
},
|
||||
{
|
||||
"a": (sub) => {
|
||||
return <a href='#' onClick={this._openBugReportDialog}>{sub}</a>;
|
||||
},
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -56,6 +57,7 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
|
@ -94,9 +96,10 @@ export default createReactClass({
|
|||
<p>{ _t("We encountered an error trying to restore your previous session.") }</p>
|
||||
|
||||
<p>{ _t(
|
||||
"If you have previously used a more recent version of Riot, your session " +
|
||||
"If you have previously used a more recent version of %(brand)s, your session " +
|
||||
"may be incompatible with this version. Close this window and return " +
|
||||
"to the more recent version.",
|
||||
{ brand },
|
||||
) }</p>
|
||||
|
||||
<p>{ _t(
|
||||
|
|
|
@ -15,13 +15,24 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import PropTypes from "prop-types";
|
||||
import * as sdk from '../../../../index';
|
||||
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
||||
import Field from '../../elements/Field';
|
||||
import AccessibleButton from '../../elements/AccessibleButton';
|
||||
|
||||
import { _t } from '../../../../languageHandler';
|
||||
import { accessSecretStorage } from '../../../../CrossSigningManager';
|
||||
|
||||
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
|
||||
// so this should be plenty and allow for people putting extra whitespace in the file because
|
||||
// maybe that's a thing people would do?
|
||||
const KEY_FILE_MAX_SIZE = 128;
|
||||
|
||||
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
|
||||
const VALIDATION_THROTTLE_MS = 200;
|
||||
|
||||
/*
|
||||
* Access Secure Secret Storage by requesting the user's passphrase.
|
||||
|
@ -36,9 +47,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._fileUpload = React.createRef();
|
||||
|
||||
this.state = {
|
||||
recoveryKey: "",
|
||||
recoveryKeyValid: false,
|
||||
recoveryKeyValid: null,
|
||||
recoveryKeyCorrect: null,
|
||||
recoveryKeyFileError: null,
|
||||
forceRecoveryKey: false,
|
||||
passPhrase: '',
|
||||
keyMatches: null,
|
||||
|
@ -55,18 +71,89 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
_onResetRecoveryClick = () => {
|
||||
// Re-enter the access flow, but resetting storage this time around.
|
||||
this.props.onFinished(false);
|
||||
accessSecretStorage(() => {}, /* forceReset = */ true);
|
||||
_validateRecoveryKeyOnChange = debounce(() => {
|
||||
this._validateRecoveryKey();
|
||||
}, VALIDATION_THROTTLE_MS);
|
||||
|
||||
async _validateRecoveryKey() {
|
||||
if (this.state.recoveryKey === '') {
|
||||
this.setState({
|
||||
recoveryKeyValid: null,
|
||||
recoveryKeyCorrect: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const decodedKey = cli.keyBackupKeyFromRecoveryKey(this.state.recoveryKey);
|
||||
const correct = await cli.checkSecretStorageKey(
|
||||
decodedKey, this.props.keyInfo,
|
||||
);
|
||||
this.setState({
|
||||
recoveryKeyValid: true,
|
||||
recoveryKeyCorrect: correct,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
recoveryKeyValid: false,
|
||||
recoveryKeyCorrect: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onRecoveryKeyChange = (e) => {
|
||||
this.setState({
|
||||
recoveryKey: e.target.value,
|
||||
recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value),
|
||||
keyMatches: null,
|
||||
recoveryKeyFileError: null,
|
||||
});
|
||||
|
||||
// also clear the file upload control so that the user can upload the same file
|
||||
// the did before (otherwise the onchange wouldn't fire)
|
||||
if (this._fileUpload.current) this._fileUpload.current.value = null;
|
||||
|
||||
// We don't use Field's validation here because a) we want it in a separate place rather
|
||||
// than in a tooltip and b) we want it to display feedback based on the uploaded file
|
||||
// as well as the text box. Ideally we would refactor Field's validation logic so we could
|
||||
// re-use some of it.
|
||||
this._validateRecoveryKeyOnChange();
|
||||
}
|
||||
|
||||
_onRecoveryKeyFileChange = async e => {
|
||||
if (e.target.files.length === 0) return;
|
||||
|
||||
const f = e.target.files[0];
|
||||
|
||||
if (f.size > KEY_FILE_MAX_SIZE) {
|
||||
this.setState({
|
||||
recoveryKeyFileError: true,
|
||||
recoveryKeyCorrect: false,
|
||||
recoveryKeyValid: false,
|
||||
});
|
||||
} else {
|
||||
const contents = await f.text();
|
||||
// test it's within the base58 alphabet. We could be more strict here, eg. require the
|
||||
// right number of characters, but it's really just to make sure that what we're reading is
|
||||
// text because we'll put it in the text field.
|
||||
if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) {
|
||||
this.setState({
|
||||
recoveryKeyFileError: null,
|
||||
recoveryKey: contents.trim(),
|
||||
});
|
||||
this._validateRecoveryKey();
|
||||
} else {
|
||||
this.setState({
|
||||
recoveryKeyFileError: true,
|
||||
recoveryKeyCorrect: false,
|
||||
recoveryKeyValid: false,
|
||||
recoveryKey: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onRecoveryKeyFileUploadClick = () => {
|
||||
this._fileUpload.current.click();
|
||||
}
|
||||
|
||||
_onPassPhraseNext = async (e) => {
|
||||
|
@ -106,6 +193,20 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
getKeyValidationText() {
|
||||
if (this.state.recoveryKeyFileError) {
|
||||
return _t("Wrong file type");
|
||||
} else if (this.state.recoveryKeyCorrect) {
|
||||
return _t("Looks good!");
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
return _t("Wrong Recovery Key");
|
||||
} else if (this.state.recoveryKeyValid === null) {
|
||||
return '';
|
||||
} else {
|
||||
return _t("Invalid Recovery Key");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
||||
|
@ -118,10 +219,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
let content;
|
||||
let title;
|
||||
let titleClass;
|
||||
if (hasPassphrase && !this.state.forceRecoveryKey) {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
title = _t("Enter recovery passphrase");
|
||||
title = _t("Security Phrase");
|
||||
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle'];
|
||||
|
||||
let keyStatus;
|
||||
if (this.state.keyMatches === false) {
|
||||
|
@ -137,12 +240,15 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
content = <div>
|
||||
<p>{_t(
|
||||
"<b>Warning</b>: You should only do this on a trusted computer.", {},
|
||||
{ b: sub => <b>{sub}</b> },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Access your secure message history and your cross-signing " +
|
||||
"identity for verifying other sessions by entering your recovery passphrase.",
|
||||
"Enter your Security Phrase or <button>Use your Security Key</button> to continue.", {},
|
||||
{
|
||||
button: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onUseRecoveryKeyClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
},
|
||||
)}</p>
|
||||
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onPassPhraseNext}>
|
||||
|
@ -153,10 +259,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
value={this.state.passPhrase}
|
||||
autoFocus={true}
|
||||
autoComplete="new-password"
|
||||
placeholder={_t("Security Phrase")}
|
||||
/>
|
||||
{keyStatus}
|
||||
<DialogButtons
|
||||
primaryButton={_t('Next')}
|
||||
primaryButton={_t('Continue')}
|
||||
onPrimaryButtonClick={this._onPassPhraseNext}
|
||||
hasCancel={true}
|
||||
onCancel={this._onCancel}
|
||||
|
@ -164,87 +271,61 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
primaryDisabled={this.state.passPhrase.length === 0}
|
||||
/>
|
||||
</form>
|
||||
{_t(
|
||||
"If you've forgotten your recovery passphrase you can "+
|
||||
"<button1>use your recovery key</button1> or " +
|
||||
"<button2>set up new recovery options</button2>."
|
||||
, {}, {
|
||||
button1: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onUseRecoveryKeyClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
button2: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
})}
|
||||
</div>;
|
||||
} else {
|
||||
title = _t("Enter recovery key");
|
||||
title = _t("Security Key");
|
||||
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle'];
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let keyStatus;
|
||||
if (this.state.recoveryKey.length === 0) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus" />;
|
||||
} else if (this.state.keyMatches === false) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t(
|
||||
"Unable to access secret storage. " +
|
||||
"Please verify that you entered the correct recovery key.",
|
||||
)}
|
||||
</div>;
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
|
||||
</div>;
|
||||
} else {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
|
||||
</div>;
|
||||
}
|
||||
const feedbackClasses = classNames({
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true,
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid': this.state.recoveryKeyCorrect === true,
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid': this.state.recoveryKeyCorrect === false,
|
||||
});
|
||||
const recoveryKeyFeedback = <div className={feedbackClasses}>
|
||||
{this.getKeyValidationText()}
|
||||
</div>;
|
||||
|
||||
content = <div>
|
||||
<p>{_t(
|
||||
"<b>Warning</b>: You should only do this on a trusted computer.", {},
|
||||
{ b: sub => <b>{sub}</b> },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Access your secure message history and your cross-signing " +
|
||||
"identity for verifying other sessions by entering your recovery key.",
|
||||
)}</p>
|
||||
<p>{_t("Use your Security Key to continue.")}</p>
|
||||
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext}>
|
||||
<input className="mx_AccessSecretStorageDialog_recoveryKeyInput"
|
||||
onChange={this._onRecoveryKeyChange}
|
||||
value={this.state.recoveryKey}
|
||||
autoFocus={true}
|
||||
/>
|
||||
{keyStatus}
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext} spellCheck={false}>
|
||||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
|
||||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
|
||||
<Field
|
||||
type="text"
|
||||
label={_t('Security Key')}
|
||||
value={this.state.recoveryKey}
|
||||
onChange={this._onRecoveryKeyChange}
|
||||
forceValidity={this.state.recoveryKeyCorrect}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">
|
||||
{_t("or")}
|
||||
</span>
|
||||
<div>
|
||||
<input type="file"
|
||||
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
|
||||
ref={this._fileUpload}
|
||||
onChange={this._onRecoveryKeyFileChange}
|
||||
/>
|
||||
<AccessibleButton kind="primary" onClick={this._onRecoveryKeyFileUploadClick}>
|
||||
{_t("Upload")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
{recoveryKeyFeedback}
|
||||
<DialogButtons
|
||||
primaryButton={_t('Next')}
|
||||
primaryButton={_t('Continue')}
|
||||
onPrimaryButtonClick={this._onRecoveryKeyNext}
|
||||
hasCancel={true}
|
||||
cancelButton={_t("Go Back")}
|
||||
cancelButtonClass='danger'
|
||||
onCancel={this._onCancel}
|
||||
focus={false}
|
||||
primaryDisabled={!this.state.recoveryKeyValid}
|
||||
/>
|
||||
</form>
|
||||
{_t(
|
||||
"If you've forgotten your recovery key you can "+
|
||||
"<button>set up new recovery options</button>."
|
||||
, {}, {
|
||||
button: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -252,6 +333,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
<BaseDialog className='mx_AccessSecretStorageDialog'
|
||||
onFinished={this.props.onFinished}
|
||||
title={title}
|
||||
titleClass={titleClass}
|
||||
>
|
||||
<div>
|
||||
{content}
|
||||
|
|
|
@ -64,7 +64,6 @@ export default function AccessibleButton({
|
|||
className,
|
||||
...restProps
|
||||
}: IProps) {
|
||||
|
||||
const newProps: IAccessibleButtonProps = restProps;
|
||||
if (!disabled) {
|
||||
newProps.onClick = onClick;
|
||||
|
@ -119,7 +118,7 @@ export default function AccessibleButton({
|
|||
AccessibleButton.defaultProps = {
|
||||
element: 'div',
|
||||
role: 'button',
|
||||
tabIndex: "0",
|
||||
tabIndex: 0,
|
||||
};
|
||||
|
||||
AccessibleButton.displayName = "AccessibleButton";
|
||||
|
|
|
@ -16,21 +16,28 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import * as sdk from "../../../index";
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export default class AccessibleTooltipButton extends React.PureComponent {
|
||||
static propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
// The tooltip to render on hover
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||
title: string;
|
||||
tooltip?: React.ReactNode;
|
||||
tooltipClassName?: string;
|
||||
}
|
||||
|
||||
state = {
|
||||
hover: false,
|
||||
};
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
export default class AccessibleTooltipButton extends React.PureComponent<ITooltipProps, IState> {
|
||||
constructor(props: ITooltipProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
this.setState({
|
||||
|
@ -38,25 +45,27 @@ export default class AccessibleTooltipButton extends React.PureComponent {
|
|||
});
|
||||
};
|
||||
|
||||
onMouseOut = () => {
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
const {title, children, ...props} = this.props;
|
||||
const {title, tooltip, children, tooltipClassName, ...props} = this.props;
|
||||
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_AccessibleTooltipButton_container"
|
||||
tooltipClassName="mx_AccessibleTooltipButton_tooltip"
|
||||
label={title}
|
||||
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
/> : <div />;
|
||||
return (
|
||||
<AccessibleButton {...props} onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} aria-label={title}>
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
aria-label={title}
|
||||
>
|
||||
{ children }
|
||||
{ tip }
|
||||
</AccessibleButton>
|
|
@ -58,18 +58,6 @@ export default createReactClass({
|
|||
imgUrls.push(require("../../../../res/img/icon-email-user.svg"));
|
||||
}
|
||||
|
||||
// Removing networks for now as they're not really supported
|
||||
/*
|
||||
var network;
|
||||
if (this.props.networkUrl !== "") {
|
||||
network = (
|
||||
<div className="mx_AddressTile_network">
|
||||
<BaseAvatar width={25} height={25} name={this.props.networkName} title="Riot" url={this.props.networkUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
|
@ -21,6 +21,7 @@ import PropTypes from 'prop-types';
|
|||
import url from 'url';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
|
||||
|
@ -76,6 +77,7 @@ export default class AppPermission extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
|
||||
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
|
||||
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
|
||||
|
@ -96,7 +98,7 @@ export default class AppPermission extends React.Component {
|
|||
<li>{_t("Your avatar URL")}</li>
|
||||
<li>{_t("Your user ID")}</li>
|
||||
<li>{_t("Your theme")}</li>
|
||||
<li>{_t("Riot URL")}</li>
|
||||
<li>{_t("%(brand)s URL", { brand })}</li>
|
||||
<li>{_t("Room ID")}</li>
|
||||
<li>{_t("Widget ID")}</li>
|
||||
</ul>
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
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 from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
const CreateRoomButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action="view_create_room"
|
||||
mouseOverAction={props.callout ? "callout_create_room" : null}
|
||||
label={_t("Create new room")}
|
||||
iconPath={require("../../../../res/img/icons-create-room.svg")}
|
||||
size={props.size}
|
||||
tooltip={props.tooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CreateRoomButton.propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CreateRoomButton;
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler.js';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import Field from "./Field";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ interface IProps {
|
|||
// to the user.
|
||||
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
|
||||
// If specified, overrides the value returned by onValidate.
|
||||
flagInvalid?: boolean;
|
||||
forceValidity?: boolean;
|
||||
// If specified, contents will appear as a tooltip on the element and
|
||||
// validation feedback tooltips will be suppressed.
|
||||
tooltipContent?: React.ReactNode;
|
||||
|
@ -203,7 +203,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
public render() {
|
||||
const {
|
||||
element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
|
||||
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
const ref = input => this.input = input;
|
||||
|
@ -228,15 +228,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
|
||||
}
|
||||
|
||||
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
|
||||
const hasValidationFlag = forceValidity !== null && forceValidity !== undefined;
|
||||
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
|
||||
// If we have a prefix element, leave the label always at the top left and
|
||||
// don't animate it, as it looks a bit clunky and would add complexity to do
|
||||
// properly.
|
||||
mx_Field_labelAlwaysTopLeft: prefixComponent,
|
||||
mx_Field_valid: onValidate && this.state.valid === true,
|
||||
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
|
||||
mx_Field_invalid: hasValidationFlag
|
||||
? flagInvalid
|
||||
? !forceValidity
|
||||
: onValidate && this.state.valid === false,
|
||||
});
|
||||
|
||||
|
@ -248,6 +248,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
|
||||
visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible}
|
||||
label={tooltipContent || this.state.feedback}
|
||||
forceOnRight
|
||||
/>;
|
||||
}
|
||||
|
||||
|
|
|
@ -27,18 +27,15 @@ export default createReactClass({
|
|||
const h = this.props.h || 16;
|
||||
const imgClass = this.props.imgClassName || "";
|
||||
|
||||
let divClass;
|
||||
let imageSource;
|
||||
if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
|
||||
divClass = "mx_InlineSpinner mx_Spinner_spin";
|
||||
imageSource = require("../../../../res/img/spinner.svg");
|
||||
} else {
|
||||
divClass = "mx_InlineSpinner";
|
||||
imageSource = require("../../../../res/img/spinner.gif");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={divClass}>
|
||||
<div className="mx_InlineSpinner">
|
||||
<img
|
||||
src={imageSource}
|
||||
width={w}
|
||||
|
|
|
@ -1,336 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 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 from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
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() {
|
||||
let container = document.getElementById(InteractiveTooltipContainerId);
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = InteractiveTooltipContainerId;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function isInRect(x, y, rect) {
|
||||
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 {integer}
|
||||
*/
|
||||
function getDiagonalSlope(rect) {
|
||||
const { top, right, bottom, left } = rect;
|
||||
return (bottom - top) / (right - left);
|
||||
}
|
||||
|
||||
function isInUpperLeftHalf(x, y, rect) {
|
||||
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, y, rect) {
|
||||
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, y, rect) {
|
||||
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, y, rect) {
|
||||
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));
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 {
|
||||
static propTypes = {
|
||||
// Content to show in the tooltip
|
||||
content: PropTypes.node.isRequired,
|
||||
// Function to call when visibility of the tooltip changes
|
||||
onVisibilityChange: PropTypes.func,
|
||||
// flag to forcefully hide this tooltip
|
||||
forceHidden: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
collectContentRect = (element) => {
|
||||
// We don't need to clean up when unmounting, so ignore
|
||||
if (!element) return;
|
||||
|
||||
this.setState({
|
||||
contentRect: element.getBoundingClientRect(),
|
||||
});
|
||||
}
|
||||
|
||||
collectTarget = (element) => {
|
||||
this.target = element;
|
||||
}
|
||||
|
||||
canTooltipFitAboveTarget() {
|
||||
const { contentRect } = this.state;
|
||||
const targetRect = this.target.getBoundingClientRect();
|
||||
const targetTop = targetRect.top + window.pageYOffset;
|
||||
return (
|
||||
!contentRect ||
|
||||
(targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE)
|
||||
);
|
||||
}
|
||||
|
||||
onMouseMove = (ev) => {
|
||||
const { clientX: x, clientY: y } = ev;
|
||||
const { contentRect } = this.state;
|
||||
const targetRect = this.target.getBoundingClientRect();
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (this.canTooltipFitAboveTarget()) {
|
||||
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.bottom,
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
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.top,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this.hideTooltip();
|
||||
}
|
||||
|
||||
onTargetMouseOver = (ev) => {
|
||||
this.showTooltip();
|
||||
}
|
||||
|
||||
showTooltip() {
|
||||
// Don't enter visible state if we haven't collected the target yet
|
||||
if (!this.target) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
visible: true,
|
||||
});
|
||||
if (this.props.onVisibilityChange) {
|
||||
this.props.onVisibilityChange(true);
|
||||
}
|
||||
document.addEventListener("mousemove", this.onMouseMove);
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
this.setState({
|
||||
visible: false,
|
||||
});
|
||||
if (this.props.onVisibilityChange) {
|
||||
this.props.onVisibilityChange(false);
|
||||
}
|
||||
document.removeEventListener("mousemove", this.onMouseMove);
|
||||
}
|
||||
|
||||
renderTooltip() {
|
||||
const { contentRect, visible } = this.state;
|
||||
if (this.props.forceHidden === true || !visible) {
|
||||
ReactDOM.render(null, 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 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 = {};
|
||||
let chevronFace = null;
|
||||
if (this.canTooltipFitAboveTarget()) {
|
||||
position.bottom = window.innerHeight - targetTop;
|
||||
chevronFace = "bottom";
|
||||
} else {
|
||||
position.top = targetBottom;
|
||||
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 === 'top',
|
||||
'mx_InteractiveTooltip_withChevron_bottom': chevronFace === 'bottom',
|
||||
});
|
||||
|
||||
const menuStyle = {};
|
||||
if (contentRect) {
|
||||
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() {
|
||||
// We use `cloneElement` here to append some props to the child content
|
||||
// without using a wrapper element which could disrupt layout.
|
||||
return React.cloneElement(this.props.children, {
|
||||
ref: this.collectTarget,
|
||||
onMouseOver: this.onTargetMouseOver,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -17,10 +17,10 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleTooltipButton from "./AccessibleTooltipButton";
|
||||
|
||||
export default class ManageIntegsButton extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -45,9 +45,8 @@ export default class ManageIntegsButton extends React.Component {
|
|||
render() {
|
||||
let integrationsButton = <div />;
|
||||
if (IntegrationManagers.sharedInstance().hasManager()) {
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
integrationsButton = (
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
className='mx_RoomHeader_button mx_RoomHeader_manageIntegsButton'
|
||||
title={_t("Manage Integrations")}
|
||||
onClick={this.onManageIntegrations}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler';
|
|||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import * as sdk from "../../../index";
|
||||
import {MatrixEvent} from "matrix-js-sdk";
|
||||
import {isValid3pidInvite} from "../../../RoomInvite";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MemberEventListSummary',
|
||||
|
@ -284,6 +285,9 @@ export default createReactClass({
|
|||
_getTransition: function(e) {
|
||||
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
||||
// Handle 3pid invites the same as invites so they get bundled together
|
||||
if (!isValid3pidInvite(e.mxEvent)) {
|
||||
return 'invite_withdrawal';
|
||||
}
|
||||
return 'invited';
|
||||
}
|
||||
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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 from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ProgressBar',
|
||||
propTypes: {
|
||||
value: PropTypes.number,
|
||||
max: PropTypes.number,
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// Would use an HTML5 progress tag but if that doesn't animate if you
|
||||
// use the HTML attributes rather than styles
|
||||
const progressStyle = {
|
||||
width: ((this.props.value / this.props.max) * 100)+"%",
|
||||
};
|
||||
return (
|
||||
<div className="mx_ProgressBar"><div className="mx_ProgressBar_fill" style={progressStyle}></div></div>
|
||||
);
|
||||
},
|
||||
});
|
28
src/components/views/elements/ProgressBar.tsx
Normal file
28
src/components/views/elements/ProgressBar.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
|
||||
interface IProps {
|
||||
value: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
const ProgressBar: React.FC<IProps> = ({value, max}) => {
|
||||
return <progress className="mx_ProgressBar" max={max} value={value} />;
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
|
@ -27,6 +27,7 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import escapeHtml from "escape-html";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
// This component does no cycle detection, simply because the only way to make such a cycle would be to
|
||||
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
|
||||
|
@ -92,7 +93,21 @@ export default class ReplyThread extends React.Component {
|
|||
|
||||
// Part of Replies fallback support
|
||||
static stripHTMLReply(html) {
|
||||
return html.replace(/^<mx-reply>[\s\S]+?<\/mx-reply>/, '');
|
||||
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
|
||||
// any HTML, since the original sender could use special tags that we
|
||||
// don't recognize, but want to pass along to any recipients who do
|
||||
// recognize them -- recipients should be sanitizing before displaying
|
||||
// anyways. However, we sanitize to 1) remove any mx-reply, so that we
|
||||
// don't generate a nested mx-reply, and 2) make sure that the HTML is
|
||||
// properly formatted (e.g. tags are closed where necessary)
|
||||
return sanitizeHtml(
|
||||
html,
|
||||
{
|
||||
allowedTags: false, // false means allow everything
|
||||
allowedAttributes: false,
|
||||
exclusiveFilter: (frame) => frame.tag === "mx-reply",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Part of Replies fallback support
|
||||
|
@ -102,15 +117,19 @@ export default class ReplyThread extends React.Component {
|
|||
let {body, formatted_body: html} = ev.getContent();
|
||||
if (this.getParentEventId(ev)) {
|
||||
if (body) body = this.stripPlainReply(body);
|
||||
if (html) html = this.stripHTMLReply(html);
|
||||
}
|
||||
|
||||
if (!body) body = ""; // Always ensure we have a body, for reasons.
|
||||
|
||||
// Escape the body to use as HTML below.
|
||||
// We also run a nl2br over the result to fix the fallback representation. We do this
|
||||
// after converting the text to safe HTML to avoid user-provided BR's from being converted.
|
||||
if (!html) html = escapeHtml(body).replace(/\n/g, '<br/>');
|
||||
if (html) {
|
||||
// sanitize the HTML before we put it in an <mx-reply>
|
||||
html = this.stripHTMLReply(html);
|
||||
} else {
|
||||
// Escape the body to use as HTML below.
|
||||
// We also run a nl2br over the result to fix the fallback representation. We do this
|
||||
// after converting the text to safe HTML to avoid user-provided BR's from being converted.
|
||||
html = escapeHtml(body).replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
// dev note: do not rely on `body` being safe for HTML usage below.
|
||||
|
||||
|
|
|
@ -21,18 +21,15 @@ import {_t} from "../../../languageHandler";
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
const Spinner = ({w = 32, h = 32, imgClassName, message}) => {
|
||||
let divClass;
|
||||
let imageSource;
|
||||
if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
|
||||
divClass = "mx_Spinner mx_Spinner_spin";
|
||||
imageSource = require("../../../../res/img/spinner.svg");
|
||||
} else {
|
||||
divClass = "mx_Spinner";
|
||||
imageSource = require("../../../../res/img/spinner.gif");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={divClass}>
|
||||
<div className="mx_Spinner">
|
||||
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message}</div> </React.Fragment> }
|
||||
<img
|
||||
src={imageSource}
|
||||
|
|
|
@ -18,6 +18,7 @@ import React from 'react';
|
|||
import classnames from 'classnames';
|
||||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
outlined?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -29,7 +30,7 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
};
|
||||
|
||||
public render() {
|
||||
const { children, className, disabled, ...otherProps } = this.props;
|
||||
const { children, className, disabled, outlined, ...otherProps } = this.props;
|
||||
const _className = classnames(
|
||||
'mx_RadioButton',
|
||||
className,
|
||||
|
@ -37,12 +38,13 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
"mx_RadioButton_disabled": disabled,
|
||||
"mx_RadioButton_enabled": !disabled,
|
||||
"mx_RadioButton_checked": this.props.checked,
|
||||
"mx_RadioButton_outlined": outlined,
|
||||
});
|
||||
return <label className={_className}>
|
||||
<input type='radio' disabled={disabled} {...otherProps} />
|
||||
{/* Used to render the radio button circle */}
|
||||
<div><div></div></div>
|
||||
<span>{children}</span>
|
||||
<div><div /></div>
|
||||
<div className="mx_RadioButton_content">{children}</div>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
</label>;
|
||||
}
|
||||
|
|
|
@ -32,24 +32,25 @@ interface IProps<T extends string> {
|
|||
className?: string;
|
||||
definitions: IDefinition<T>[];
|
||||
value?: T; // if not provided no options will be selected
|
||||
outlined?: boolean;
|
||||
onChange(newValue: T);
|
||||
}
|
||||
|
||||
function StyledRadioGroup<T extends string>({name, definitions, value, className, onChange}: IProps<T>) {
|
||||
function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) {
|
||||
const _onChange = e => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return <React.Fragment>
|
||||
{definitions.map(d => <React.Fragment>
|
||||
{definitions.map(d => <React.Fragment key={d.value}>
|
||||
<StyledRadioButton
|
||||
key={d.value}
|
||||
className={classNames(className, d.className)}
|
||||
onChange={_onChange}
|
||||
checked={d.value === value}
|
||||
name={name}
|
||||
value={d.value}
|
||||
disabled={d.disabled}
|
||||
outlined={outlined}
|
||||
>
|
||||
{d.label}
|
||||
</StyledRadioButton>
|
||||
|
|
|
@ -29,6 +29,7 @@ import FlairStore from '../../../stores/FlairStore';
|
|||
import GroupStore from '../../../stores/GroupStore';
|
||||
import TagOrderStore from '../../../stores/TagOrderStore';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
|
||||
// a thing to click on for the user to filter the visible rooms in the RoomList to:
|
||||
|
@ -114,7 +115,7 @@ export default createReactClass({
|
|||
this.setState({ hover: true });
|
||||
},
|
||||
|
||||
onMouseOut: function() {
|
||||
onMouseLeave: function() {
|
||||
this.setState({ hover: false });
|
||||
},
|
||||
|
||||
|
@ -130,7 +131,7 @@ export default createReactClass({
|
|||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
const profile = this.state.profile || {};
|
||||
const name = profile.name || this.props.tag;
|
||||
const avatarHeight = 40;
|
||||
const avatarHeight = 32;
|
||||
|
||||
const httpUrl = profile.avatarUrl ? this.context.mxcUrlToHttp(
|
||||
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
|
||||
|
@ -151,11 +152,14 @@ export default createReactClass({
|
|||
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
|
||||
}
|
||||
|
||||
// FIXME: this ought to use AccessibleButton for a11y but that causes onMouseOut/onMouseOver to fire too much
|
||||
const contextButton = this.state.hover || this.props.menuDisplayed ?
|
||||
<div className="mx_TagTile_context_button" onClick={this.openMenu} ref={this.props.contextMenuButtonRef}>
|
||||
<AccessibleButton
|
||||
className="mx_TagTile_context_button"
|
||||
onClick={this.openMenu}
|
||||
ref={this.props.contextMenuButtonRef}
|
||||
>
|
||||
{"\u00B7\u00B7\u00B7"}
|
||||
</div> : <div ref={this.props.contextMenuButtonRef} />;
|
||||
</AccessibleButton> : <div ref={this.props.contextMenuButtonRef} />;
|
||||
|
||||
const AccessibleTooltipButton = sdk.getComponent("elements.AccessibleTooltipButton");
|
||||
|
||||
|
@ -168,7 +172,7 @@ export default createReactClass({
|
|||
<div
|
||||
className="mx_TagTile_avatar"
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseOut={this.onMouseOut}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<BaseAvatar
|
||||
name={name}
|
||||
|
|
|
@ -37,7 +37,7 @@ export default class TextWithTooltip extends React.Component {
|
|||
this.setState({hover: true});
|
||||
};
|
||||
|
||||
onMouseOut = () => {
|
||||
onMouseLeave = () => {
|
||||
this.setState({hover: false});
|
||||
};
|
||||
|
||||
|
@ -45,7 +45,7 @@ export default class TextWithTooltip extends React.Component {
|
|||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
|
||||
return (
|
||||
<span onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} className={this.props.class}>
|
||||
<span onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={this.props.class}>
|
||||
{this.props.children}
|
||||
<Tooltip
|
||||
label={this.props.tooltip}
|
||||
|
|
|
@ -18,18 +18,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import React, {Component, CSSProperties} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import classNames from 'classnames';
|
||||
import { ViewTooltipPayload } from '../../../dispatcher/payloads/ViewTooltipPayload';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
|
||||
const MIN_TOOLTIP_HEIGHT = 25;
|
||||
|
||||
interface IProps {
|
||||
// Class applied to the element used to position the tooltip
|
||||
className: string;
|
||||
className?: string;
|
||||
// Class applied to the tooltip itself
|
||||
tooltipClassName?: string;
|
||||
// Whether the tooltip is visible or hidden.
|
||||
|
@ -38,6 +35,7 @@ interface IProps {
|
|||
visible?: boolean;
|
||||
// the react element to put into the tooltip
|
||||
label: React.ReactNode;
|
||||
forceOnRight?: boolean;
|
||||
}
|
||||
|
||||
export default class Tooltip extends React.Component<IProps> {
|
||||
|
@ -68,18 +66,12 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
|
||||
// Remove the wrapper element, as the tooltip has finished using it
|
||||
public componentWillUnmount() {
|
||||
dis.dispatch<ViewTooltipPayload>({
|
||||
action: Action.ViewTooltip,
|
||||
tooltip: null,
|
||||
parent: null,
|
||||
});
|
||||
|
||||
ReactDOM.unmountComponentAtNode(this.tooltipContainer);
|
||||
document.body.removeChild(this.tooltipContainer);
|
||||
window.removeEventListener('scroll', this.renderTooltip, true);
|
||||
}
|
||||
|
||||
private updatePosition(style: {[key: string]: any}) {
|
||||
private updatePosition(style: CSSProperties) {
|
||||
const parentBox = this.parent.getBoundingClientRect();
|
||||
let offset = 0;
|
||||
if (parentBox.height > MIN_TOOLTIP_HEIGHT) {
|
||||
|
@ -89,8 +81,14 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
// we need so that we're still centered.
|
||||
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
|
||||
}
|
||||
|
||||
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
|
||||
style.left = 6 + parentBox.right + window.pageXOffset;
|
||||
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
|
||||
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 8;
|
||||
} else {
|
||||
style.left = parentBox.right + window.pageXOffset + 6;
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
|
@ -99,7 +97,6 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
// positioned, also taking into account any window zoom
|
||||
// NOTE: The additional 6 pixels for the left position, is to take account of the
|
||||
// tooltips chevron
|
||||
const parent = ReactDOM.findDOMNode(this).parentNode as Element;
|
||||
const style = this.updatePosition({});
|
||||
// Hide the entire container when not visible. This prevents flashing of the tooltip
|
||||
// if it is not meant to be visible on first mount.
|
||||
|
@ -119,19 +116,12 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
|
||||
// Render the tooltip manually, as we wish it not to be rendered within the parent
|
||||
this.tooltip = ReactDOM.render<Element>(tooltip, this.tooltipContainer);
|
||||
|
||||
// Tell the roomlist about us so it can manipulate us if it wishes
|
||||
dis.dispatch<ViewTooltipPayload>({
|
||||
action: Action.ViewTooltip,
|
||||
tooltip: this.tooltip,
|
||||
parent: parent,
|
||||
});
|
||||
};
|
||||
|
||||
public render() {
|
||||
// Render a placeholder
|
||||
return (
|
||||
<div className={this.props.className} >
|
||||
<div className={this.props.className}>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ export default createReactClass({
|
|||
});
|
||||
},
|
||||
|
||||
onMouseOut: function() {
|
||||
onMouseLeave: function() {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
|
@ -48,7 +48,7 @@ export default createReactClass({
|
|||
label={this.props.helpText}
|
||||
/> : <div />;
|
||||
return (
|
||||
<div className="mx_TooltipButton" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} >
|
||||
<div className="mx_TooltipButton" onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave}>
|
||||
?
|
||||
{ tip }
|
||||
</div>
|
||||
|
|
|
@ -24,7 +24,6 @@ import GroupStore from '../../../stores/GroupStore';
|
|||
import PropTypes from 'prop-types';
|
||||
import { showGroupInviteDialog } from '../../../GroupAddressPicker';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import TintableSvg from '../elements/TintableSvg';
|
||||
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
|
||||
|
@ -211,15 +210,13 @@ export default createReactClass({
|
|||
let inviteButton;
|
||||
if (GroupStore.isUserPrivileged(this.props.groupId)) {
|
||||
inviteButton = (
|
||||
<AccessibleButton
|
||||
className="mx_RightPanel_invite"
|
||||
onClick={this.onInviteToGroupButtonClick}
|
||||
>
|
||||
<div className="mx_RightPanel_icon" >
|
||||
<TintableSvg src={require("../../../../res/img/icon-invite-people.svg")} width="18" height="14" />
|
||||
</div>
|
||||
<div className="mx_RightPanel_message">{ _t('Invite to this community') }</div>
|
||||
</AccessibleButton>);
|
||||
<AccessibleButton
|
||||
className="mx_MemberList_invite mx_MemberList_inviteCommunity"
|
||||
onClick={this.onInviteToGroupButtonClick}
|
||||
>
|
||||
<span>{ _t('Invite to this community') }</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -21,7 +21,6 @@ import GroupStore from '../../../stores/GroupStore';
|
|||
import PropTypes from 'prop-types';
|
||||
import { showGroupAddRoomDialog } from '../../../GroupAddressPicker';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import TintableSvg from '../elements/TintableSvg';
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
|
||||
const INITIAL_LOAD_NUM_ROOMS = 30;
|
||||
|
@ -135,13 +134,10 @@ export default createReactClass({
|
|||
if (GroupStore.isUserPrivileged(this.props.groupId)) {
|
||||
inviteButton = (
|
||||
<AccessibleButton
|
||||
className="mx_RightPanel_invite"
|
||||
className="mx_MemberList_invite mx_MemberList_addRoomToCommunity"
|
||||
onClick={this.onAddRoomToGroupButtonClick}
|
||||
>
|
||||
<div className="mx_RightPanel_icon" >
|
||||
<TintableSvg src={require("../../../../res/img/icons-room-add.svg")} width="18" height="14" />
|
||||
</div>
|
||||
<div className="mx_RightPanel_message">{ _t('Add rooms to this community') }</div>
|
||||
<span>{ _t('Add rooms to this community') }</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,12 +22,15 @@ import PropTypes from 'prop-types';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu';
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from '../../structures/ContextMenu';
|
||||
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
|
||||
|
||||
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
|
@ -52,12 +55,14 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
|
|||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuButton
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||
label={_t("Options")}
|
||||
title={_t("Options")}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={button}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
|
@ -66,6 +71,7 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
|
|||
|
||||
const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
|
@ -80,12 +86,14 @@ const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
|
|||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuButton
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton"
|
||||
label={_t("React")}
|
||||
title={_t("React")}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={button}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
|
@ -148,8 +156,6 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
};
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let reactButton;
|
||||
let replyButton;
|
||||
let editButton;
|
||||
|
@ -161,7 +167,7 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
);
|
||||
}
|
||||
if (this.context.canReply) {
|
||||
replyButton = <AccessibleButton
|
||||
replyButton = <RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
|
||||
title={_t("Reply")}
|
||||
onClick={this.onReplyClick}
|
||||
|
@ -169,7 +175,7 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
if (canEditContent(this.props.mxEvent)) {
|
||||
editButton = <AccessibleButton
|
||||
editButton = <RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
|
||||
title={_t("Edit")}
|
||||
onClick={this.onEditClick}
|
||||
|
@ -177,7 +183,7 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
}
|
||||
|
||||
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
||||
return <div className="mx_MessageActionBar" role="toolbar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
{reactButton}
|
||||
{replyButton}
|
||||
{editButton}
|
||||
|
@ -188,6 +194,6 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
permalinkCreator={this.props.permalinkCreator}
|
||||
onFocusChange={this.onFocusChange}
|
||||
/>
|
||||
</div>;
|
||||
</Toolbar>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,7 +74,7 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
onMouseOut = () => {
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
tooltipVisible: false,
|
||||
});
|
||||
|
@ -129,11 +129,12 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
}
|
||||
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return <AccessibleButton className={classes}
|
||||
return <AccessibleButton
|
||||
className={classes}
|
||||
aria-label={label}
|
||||
onClick={this.onClick}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseOut={this.onMouseOut}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<span className="mx_ReactionsRowButton_content" aria-hidden="true">
|
||||
{content}
|
||||
|
|
|
@ -55,7 +55,7 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent {
|
|||
},
|
||||
{
|
||||
reactors: () => {
|
||||
return <div className="mx_ReactionsRowButtonTooltip_senders">
|
||||
return <div className="mx_Tooltip_title">
|
||||
{formatCommaSeparatedList(senders, 6)}
|
||||
</div>;
|
||||
},
|
||||
|
@ -63,7 +63,7 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent {
|
|||
if (!shortName) {
|
||||
return null;
|
||||
}
|
||||
return <div className="mx_ReactionsRowButtonTooltip_reactedWith">
|
||||
return <div className="mx_Tooltip_sub">
|
||||
{sub}
|
||||
</div>;
|
||||
},
|
||||
|
@ -73,11 +73,7 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent {
|
|||
|
||||
let tooltip;
|
||||
if (tooltipLabel) {
|
||||
tooltip = <Tooltip
|
||||
tooltipClassName="mx_Tooltip_timeline"
|
||||
visible={visible}
|
||||
label={tooltipLabel}
|
||||
/>;
|
||||
tooltip = <Tooltip visible={visible} label={tooltipLabel} />;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
|
|
|
@ -125,8 +125,10 @@ export default createReactClass({
|
|||
</span>;
|
||||
|
||||
const content = this.props.text ?
|
||||
<span className="mx_SenderProfile_aux">
|
||||
{ _t(this.props.text, { senderName: () => nameElem }) }
|
||||
<span>
|
||||
<span className="mx_SenderProfile_aux">
|
||||
{ _t(this.props.text, { senderName: () => nameElem }) }
|
||||
</span>
|
||||
</span> : nameFlair;
|
||||
|
||||
return (
|
||||
|
|
|
@ -35,6 +35,7 @@ import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
|||
import {isPermalinkHost} from "../../../utils/permalinks/Permalinks";
|
||||
import {toRightOf} from "../../structures/ContextMenu";
|
||||
import {copyPlaintext} from "../../../utils/strings";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'TextualBody',
|
||||
|
@ -146,7 +147,6 @@ export default createReactClass({
|
|||
nextProps.showUrlPreview !== this.props.showUrlPreview ||
|
||||
nextProps.editState !== this.props.editState ||
|
||||
nextState.links !== this.state.links ||
|
||||
nextState.editedMarkerHovered !== this.state.editedMarkerHovered ||
|
||||
nextState.widgetHidden !== this.state.widgetHidden);
|
||||
},
|
||||
|
||||
|
@ -367,42 +367,33 @@ export default createReactClass({
|
|||
});
|
||||
},
|
||||
|
||||
_onMouseEnterEditedMarker: function() {
|
||||
this.setState({editedMarkerHovered: true});
|
||||
},
|
||||
|
||||
_onMouseLeaveEditedMarker: function() {
|
||||
this.setState({editedMarkerHovered: false});
|
||||
},
|
||||
|
||||
_openHistoryDialog: async function() {
|
||||
const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
|
||||
Modal.createDialog(MessageEditHistoryDialog, {mxEvent: this.props.mxEvent});
|
||||
},
|
||||
|
||||
_renderEditedMarker: function() {
|
||||
let editedTooltip;
|
||||
if (this.state.editedMarkerHovered) {
|
||||
const Tooltip = sdk.getComponent('elements.Tooltip');
|
||||
const date = this.props.mxEvent.replacingEventDate();
|
||||
const dateString = date && formatDate(date);
|
||||
editedTooltip = <Tooltip
|
||||
tooltipClassName="mx_Tooltip_timeline"
|
||||
label={_t("Edited at %(date)s. Click to view edits.", {date: dateString})}
|
||||
/>;
|
||||
}
|
||||
const date = this.props.mxEvent.replacingEventDate();
|
||||
const dateString = date && formatDate(date);
|
||||
|
||||
const tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{_t("Edited at %(date)s", {date: dateString})}
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{_t("Click to view edits")}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton
|
||||
key="editedMarker"
|
||||
<AccessibleTooltipButton
|
||||
className="mx_EventTile_edited"
|
||||
onClick={this._openHistoryDialog}
|
||||
onMouseEnter={this._onMouseEnterEditedMarker}
|
||||
onMouseLeave={this._onMouseLeaveEditedMarker}
|
||||
title={_t("Edited at %(date)s. Click to view edits.", {date: dateString})}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
{ editedTooltip }<span>{`(${_t("edited")})`}</span>
|
||||
</AccessibleButton>
|
||||
<span>{`(${_t("edited")})`}</span>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
},
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Analytics from '../../../Analytics';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
export default class HeaderButton extends React.Component {
|
||||
constructor() {
|
||||
|
@ -42,13 +42,13 @@ export default class HeaderButton extends React.Component {
|
|||
[`mx_RightPanel_${this.props.name}`]: true,
|
||||
});
|
||||
|
||||
return <AccessibleButton
|
||||
return <AccessibleTooltipButton
|
||||
aria-selected={this.props.isHighlighted}
|
||||
role="tab"
|
||||
title={this.props.title}
|
||||
className={classes}
|
||||
onClick={this.onClick}>
|
||||
</AccessibleButton>;
|
||||
onClick={this.onClick}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1287,11 +1287,11 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
|||
);
|
||||
|
||||
// only display the devices list if our client supports E2E
|
||||
const _enableDevices = cli.isCryptoEnabled();
|
||||
const cryptoEnabled = cli.isCryptoEnabled();
|
||||
|
||||
let text;
|
||||
if (!isRoomEncrypted) {
|
||||
if (!_enableDevices) {
|
||||
if (!cryptoEnabled) {
|
||||
text = _t("This client does not support end-to-end encryption.");
|
||||
} else if (room) {
|
||||
text = _t("Messages in this room are not end-to-end encrypted.");
|
||||
|
@ -1305,10 +1305,10 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
|||
let verifyButton;
|
||||
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
|
||||
|
||||
const userTrust = cli.checkUserTrust(member.userId);
|
||||
const userVerified = userTrust.isCrossSigningVerified();
|
||||
const userTrust = cryptoEnabled && cli.checkUserTrust(member.userId);
|
||||
const userVerified = cryptoEnabled && userTrust.isCrossSigningVerified();
|
||||
const isMe = member.userId === cli.getUserId();
|
||||
const canVerify = homeserverSupportsCrossSigning && !userVerified && !isMe;
|
||||
const canVerify = cryptoEnabled && homeserverSupportsCrossSigning && !userVerified && !isMe;
|
||||
|
||||
const setUpdating = (updating) => {
|
||||
setPendingUpdateCount(count => count + (updating ? 1 : -1));
|
||||
|
@ -1345,10 +1345,10 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
|||
<h3>{ _t("Security") }</h3>
|
||||
<p>{ text }</p>
|
||||
{ verifyButton }
|
||||
<DevicesSection
|
||||
{ cryptoEnabled && <DevicesSection
|
||||
loading={showDeviceListSpinner}
|
||||
devices={devices}
|
||||
userId={member.userId} />
|
||||
userId={member.userId} /> }
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import {SCAN_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"
|
|||
|
||||
import VerificationQRCode from "../elements/crypto/VerificationQRCode";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import E2EIcon from "../rooms/E2EIcon";
|
||||
import {
|
||||
PHASE_UNSENT,
|
||||
|
@ -63,9 +64,15 @@ export default class VerificationPanel extends React.PureComponent {
|
|||
const showSAS = request.otherPartySupportsMethod(verificationMethods.SAS);
|
||||
const showQR = request.otherPartySupportsMethod(SCAN_QR_CODE_METHOD);
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
const noCommonMethodError = !showSAS && !showQR ?
|
||||
<p>{_t("The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what Riot supports. Try with a different client.")}</p> :
|
||||
<p>{_t(
|
||||
"The session you are trying to verify doesn't support scanning a " +
|
||||
"QR code or emoji verification, which is what %(brand)s supports. Try " +
|
||||
"with a different client.",
|
||||
{ brand },
|
||||
)}</p> :
|
||||
null;
|
||||
|
||||
if (this.props.layout === 'dialog') {
|
||||
|
|
|
@ -28,6 +28,7 @@ import classNames from 'classnames';
|
|||
import RateLimitedFunc from '../../../ratelimitedfunc';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import CallView from "../voip/CallView";
|
||||
|
||||
|
||||
export default createReactClass({
|
||||
|
@ -142,7 +143,6 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const CallView = sdk.getComponent("voip.CallView");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
let fileDropTarget = null;
|
||||
|
|
|
@ -42,11 +42,12 @@ const crossSigningRoomTitles = {
|
|||
[E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"),
|
||||
};
|
||||
|
||||
const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
|
||||
const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip, bordered}) => {
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
const classes = classNames({
|
||||
mx_E2EIcon: true,
|
||||
mx_E2EIcon_bordered: bordered,
|
||||
mx_E2EIcon_warning: status === E2E_STATE.WARNING,
|
||||
mx_E2EIcon_normal: status === E2E_STATE.NORMAL,
|
||||
mx_E2EIcon_verified: status === E2E_STATE.VERIFIED,
|
||||
|
@ -65,7 +66,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
|
|||
}
|
||||
|
||||
const onMouseOver = () => setHover(true);
|
||||
const onMouseOut = () => setHover(false);
|
||||
const onMouseLeave = () => setHover(false);
|
||||
|
||||
let tip;
|
||||
if (hover && !hideTooltip) {
|
||||
|
@ -77,7 +78,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
|
|||
<AccessibleButton
|
||||
onClick={onClick}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={classes}
|
||||
style={style}
|
||||
>
|
||||
|
@ -86,7 +87,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
|
|||
);
|
||||
}
|
||||
|
||||
return <div onMouseOver={onMouseOver} onMouseOut={onMouseOut} className={classes} style={style}>
|
||||
return <div onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} className={classes} style={style}>
|
||||
{ tip }
|
||||
</div>;
|
||||
};
|
||||
|
|
|
@ -170,7 +170,7 @@ const EntityTile = createReactClass({
|
|||
let e2eIcon;
|
||||
const { e2eStatus } = this.props;
|
||||
if (e2eStatus) {
|
||||
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} />;
|
||||
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} bordered={true} />;
|
||||
}
|
||||
|
||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
|
|
|
@ -333,7 +333,7 @@ export default createReactClass({
|
|||
return;
|
||||
}
|
||||
|
||||
const eventSenderTrust = this.context.checkDeviceTrust(
|
||||
const eventSenderTrust = encryptionInfo.sender && this.context.checkDeviceTrust(
|
||||
senderId, encryptionInfo.sender.deviceId,
|
||||
);
|
||||
if (!eventSenderTrust) {
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
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 React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
|
||||
export default class InviteOnlyIcon extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
onHoverStart = () => {
|
||||
this.setState({hover: true});
|
||||
};
|
||||
|
||||
onHoverEnd = () => {
|
||||
this.setState({hover: false});
|
||||
};
|
||||
|
||||
render() {
|
||||
const classes = this.props.collapsedPanel ? "mx_InviteOnlyIcon_small": "mx_InviteOnlyIcon_large";
|
||||
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
let tooltip;
|
||||
if (this.state.hover) {
|
||||
tooltip = <Tooltip className="mx_InviteOnlyIcon_tooltip" label={_t("Invite only")} dir="auto" />;
|
||||
}
|
||||
return (<div className={classes}
|
||||
onMouseEnter={this.onHoverStart}
|
||||
onMouseLeave={this.onHoverEnd}
|
||||
>
|
||||
{ tooltip }
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -16,13 +16,18 @@ limitations under the License.
|
|||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default (props) => {
|
||||
const className = classNames({
|
||||
'mx_JumpToBottomButton': true,
|
||||
'mx_JumpToBottomButton_highlight': props.highlight,
|
||||
});
|
||||
let badge;
|
||||
if (props.numUnreadMessages) {
|
||||
badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>);
|
||||
}
|
||||
return (<div className="mx_JumpToBottomButton">
|
||||
return (<div className={className}>
|
||||
<AccessibleButton className="mx_JumpToBottomButton_scrollDown"
|
||||
title={_t("Scroll to most recent messages")}
|
||||
onClick={props.onScrollToBottomClick}>
|
||||
|
|
|
@ -27,7 +27,8 @@ import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
|
|||
import ContentMessages from '../../../ContentMessages';
|
||||
import E2EIcon from './E2EIcon';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
function ComposerAvatar(props) {
|
||||
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
|
||||
|
@ -41,7 +42,6 @@ ComposerAvatar.propTypes = {
|
|||
};
|
||||
|
||||
function CallButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const onVoiceCallClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
|
@ -50,10 +50,11 @@ function CallButton(props) {
|
|||
});
|
||||
};
|
||||
|
||||
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
|
||||
onClick={onVoiceCallClick}
|
||||
title={_t('Voice call')}
|
||||
/>);
|
||||
return (<AccessibleTooltipButton
|
||||
className="mx_MessageComposer_button mx_MessageComposer_voicecall"
|
||||
onClick={onVoiceCallClick}
|
||||
title={_t('Voice call')}
|
||||
/>);
|
||||
}
|
||||
|
||||
CallButton.propTypes = {
|
||||
|
@ -61,7 +62,6 @@ CallButton.propTypes = {
|
|||
};
|
||||
|
||||
function VideoCallButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const onCallClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
|
@ -70,7 +70,8 @@ function VideoCallButton(props) {
|
|||
});
|
||||
};
|
||||
|
||||
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_videocall"
|
||||
return <AccessibleTooltipButton
|
||||
className="mx_MessageComposer_button mx_MessageComposer_videocall"
|
||||
onClick={onCallClick}
|
||||
title={_t('Video call')}
|
||||
/>;
|
||||
|
@ -117,14 +118,15 @@ const EmojiButton = ({addEmoji}) => {
|
|||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuButton className="mx_MessageComposer_button mx_MessageComposer_emoji"
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
label={_t('Emoji picker')}
|
||||
inputRef={button}
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageComposer_button mx_MessageComposer_emoji"
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
title={_t('Emoji picker')}
|
||||
inputRef={button}
|
||||
>
|
||||
|
||||
</ContextMenuButton>
|
||||
</ContextMenuTooltipButton>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
|
@ -185,9 +187,9 @@ class UploadButton extends React.Component {
|
|||
|
||||
render() {
|
||||
const uploadInputStyle = {display: 'none'};
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_upload"
|
||||
<AccessibleTooltipButton
|
||||
className="mx_MessageComposer_button mx_MessageComposer_upload"
|
||||
onClick={this.onUploadClick}
|
||||
title={_t('Upload file')}
|
||||
>
|
||||
|
@ -198,7 +200,7 @@ class UploadButton extends React.Component {
|
|||
multiple
|
||||
onChange={this.onUploadFileInputChange}
|
||||
/>
|
||||
</AccessibleButton>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,8 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
export default class MessageComposerFormatBar extends React.PureComponent {
|
||||
static propTypes = {
|
||||
|
@ -68,28 +67,28 @@ class FormatButton extends React.PureComponent {
|
|||
};
|
||||
|
||||
render() {
|
||||
const InteractiveTooltip = sdk.getComponent('elements.InteractiveTooltip');
|
||||
const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`;
|
||||
let shortcut;
|
||||
if (this.props.shortcut) {
|
||||
shortcut = <div className="mx_MessageComposerFormatBar_tooltipShortcut">{this.props.shortcut}</div>;
|
||||
}
|
||||
const tooltipContent = (
|
||||
<div className="mx_MessageComposerFormatBar_buttonTooltip">
|
||||
<div>{this.props.label}</div>
|
||||
const tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{this.props.label}
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{shortcut}
|
||||
</div>
|
||||
);
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<InteractiveTooltip content={tooltipContent} forceHidden={!this.props.visible}>
|
||||
<AccessibleButton
|
||||
as="span"
|
||||
role="button"
|
||||
onClick={this.props.onClick}
|
||||
aria-label={this.props.label}
|
||||
className={className} />
|
||||
</InteractiveTooltip>
|
||||
<AccessibleTooltipButton
|
||||
as="span"
|
||||
role="button"
|
||||
onClick={this.props.onClick}
|
||||
title={this.props.label}
|
||||
tooltip={tooltip}
|
||||
className={className} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,37 +17,13 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
||||
import * as Unread from '../../../Unread';
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { EventEmitter } from "events";
|
||||
import { arrayDiff } from "../../../utils/arrays";
|
||||
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
|
||||
|
||||
export const NOTIFICATION_STATE_UPDATE = "update";
|
||||
|
||||
export enum NotificationColor {
|
||||
// Inverted (None -> Red) because we do integer comparisons on this
|
||||
None, // nothing special
|
||||
Bold, // no badge, show as unread // TODO: This goes away with new notification structures
|
||||
Grey, // unread notified messages
|
||||
Red, // unread pings
|
||||
}
|
||||
|
||||
export interface INotificationState extends EventEmitter {
|
||||
symbol?: string;
|
||||
count: number;
|
||||
color: NotificationColor;
|
||||
}
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { XOR } from "../../../@types/common";
|
||||
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
|
||||
interface IProps {
|
||||
notification: INotificationState;
|
||||
notification: NotificationState;
|
||||
|
||||
/**
|
||||
* If true, the badge will show a count if at all possible. This is typically
|
||||
|
@ -61,11 +37,18 @@ interface IProps {
|
|||
roomId?: string;
|
||||
}
|
||||
|
||||
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
|
||||
/**
|
||||
* If specified will return an AccessibleButton instead of a div.
|
||||
*/
|
||||
onClick?(ev: React.MouseEvent);
|
||||
}
|
||||
|
||||
interface IState {
|
||||
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
|
||||
}
|
||||
|
||||
export default class NotificationBadge extends React.PureComponent<IProps, IState> {
|
||||
export default class NotificationBadge extends React.PureComponent<XOR<IProps, IClickableProps>, IState> {
|
||||
private countWatcherRef: string;
|
||||
|
||||
constructor(props: IProps) {
|
||||
|
@ -108,31 +91,42 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
|
|||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// Don't show a badge if we don't need to
|
||||
if (this.props.notification.color <= NotificationColor.None) return null;
|
||||
const {notification, forceCount, roomId, onClick, ...props} = this.props;
|
||||
|
||||
const hasNotif = this.props.notification.color >= NotificationColor.Red;
|
||||
const hasCount = this.props.notification.color >= NotificationColor.Grey;
|
||||
const hasUnread = this.props.notification.color >= NotificationColor.Bold;
|
||||
const couldBeEmpty = (!this.state.showCounts || hasUnread) && !hasNotif;
|
||||
let isEmptyBadge = couldBeEmpty && (!this.state.showCounts || !hasCount);
|
||||
if (this.props.forceCount) {
|
||||
// Don't show a badge if we don't need to
|
||||
if (notification.isIdle) return null;
|
||||
|
||||
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
|
||||
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
|
||||
// See git diff for what that boolean state looks like.
|
||||
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
|
||||
const hasAnySymbol = notification.symbol || notification.count > 0;
|
||||
let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
|
||||
if (forceCount) {
|
||||
isEmptyBadge = false;
|
||||
if (!hasCount) return null; // Can't render a badge
|
||||
if (!notification.hasUnreadCount) return null; // Can't render a badge
|
||||
}
|
||||
|
||||
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
|
||||
let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
|
||||
if (isEmptyBadge) symbol = "";
|
||||
|
||||
const classes = classNames({
|
||||
'mx_NotificationBadge': true,
|
||||
'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount,
|
||||
'mx_NotificationBadge_highlighted': hasNotif,
|
||||
'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount,
|
||||
'mx_NotificationBadge_highlighted': notification.hasMentions,
|
||||
'mx_NotificationBadge_dot': isEmptyBadge,
|
||||
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
|
||||
'mx_NotificationBadge_3char': symbol.length > 2,
|
||||
});
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<AccessibleButton {...props} className={classes} onClick={onClick}>
|
||||
<span className="mx_NotificationBadge_count">{symbol}</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<span className="mx_NotificationBadge_count">{symbol}</span>
|
||||
|
@ -140,240 +134,3 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class StaticNotificationState extends EventEmitter implements INotificationState {
|
||||
constructor(public symbol: string, public count: number, public color: NotificationColor) {
|
||||
super();
|
||||
}
|
||||
|
||||
public static forCount(count: number, color: NotificationColor): StaticNotificationState {
|
||||
return new StaticNotificationState(null, count, color);
|
||||
}
|
||||
|
||||
public static forSymbol(symbol: string, color: NotificationColor): StaticNotificationState {
|
||||
return new StaticNotificationState(symbol, 0, color);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState {
|
||||
private _symbol: string;
|
||||
private _count: number;
|
||||
private _color: NotificationColor;
|
||||
|
||||
constructor(private room: Room) {
|
||||
super();
|
||||
this.room.on("Room.receipt", this.handleReadReceipt);
|
||||
this.room.on("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.on("Room.redaction", this.handleRoomEventUpdate);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
||||
this.updateNotificationState();
|
||||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return this._symbol;
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
return this._count;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
private get roomIsInvite(): boolean {
|
||||
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.room.removeListener("Room.receipt", this.handleReadReceipt);
|
||||
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private handleReadReceipt = (event: MatrixEvent, room: Room) => {
|
||||
if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore
|
||||
if (room.roomId !== this.room.roomId) return; // not for us - ignore
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
private handleRoomEventUpdate = (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
|
||||
if (roomId !== this.room.roomId) return; // ignore - not for us
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
private updateNotificationState() {
|
||||
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
|
||||
if (this.roomIsInvite) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._symbol = "!";
|
||||
this._count = 1; // not used, technically
|
||||
} else {
|
||||
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight');
|
||||
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total');
|
||||
|
||||
// For a 'true count' we pick the grey notifications first because they include the
|
||||
// red notifications. If we don't have a grey count for some reason we use the red
|
||||
// count. If that count is broken for some reason, assume zero. This avoids us showing
|
||||
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
|
||||
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
|
||||
|
||||
// Note: we only set the symbol if we have an actual count. We don't want to show
|
||||
// zero on badges.
|
||||
|
||||
if (redNotifs > 0) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._count = trueCount;
|
||||
this._symbol = null; // symbol calculated by component
|
||||
} else if (greyNotifs > 0) {
|
||||
this._color = NotificationColor.Grey;
|
||||
this._count = trueCount;
|
||||
this._symbol = null; // symbol calculated by component
|
||||
} else {
|
||||
// We don't have any notified messages, but we might have unread messages. Let's
|
||||
// find out.
|
||||
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room);
|
||||
if (hasUnread) {
|
||||
this._color = NotificationColor.Bold;
|
||||
} else {
|
||||
this._color = NotificationColor.None;
|
||||
}
|
||||
|
||||
// no symbol or count for this state
|
||||
this._count = 0;
|
||||
this._symbol = null;
|
||||
}
|
||||
}
|
||||
|
||||
// finally, publish an update if needed
|
||||
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TagSpecificNotificationState extends RoomNotificationState {
|
||||
private static TAG_TO_COLOR: {
|
||||
// @ts-ignore - TS wants this to be a string key, but we know better
|
||||
[tagId: TagID]: NotificationColor,
|
||||
} = {
|
||||
[DefaultTagID.DM]: NotificationColor.Red,
|
||||
};
|
||||
|
||||
private readonly colorWhenNotIdle?: NotificationColor;
|
||||
|
||||
constructor(room: Room, tagId: TagID) {
|
||||
super(room);
|
||||
|
||||
const specificColor = TagSpecificNotificationState.TAG_TO_COLOR[tagId];
|
||||
if (specificColor) this.colorWhenNotIdle = specificColor;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
if (!this.colorWhenNotIdle) return super.color;
|
||||
|
||||
if (super.color !== NotificationColor.None) return this.colorWhenNotIdle;
|
||||
return super.color;
|
||||
}
|
||||
}
|
||||
|
||||
export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState {
|
||||
private _count: number;
|
||||
private _color: NotificationColor;
|
||||
private rooms: Room[] = [];
|
||||
private states: { [roomId: string]: RoomNotificationState } = {};
|
||||
|
||||
constructor(private byTileCount = false, private tagId: TagID) {
|
||||
super();
|
||||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return null; // This notification state doesn't support symbols
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
return this._count;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
public setRooms(rooms: Room[]) {
|
||||
// If we're only concerned about the tile count, don't bother setting up listeners.
|
||||
if (this.byTileCount) {
|
||||
this.rooms = rooms;
|
||||
this.calculateTotalState();
|
||||
return;
|
||||
}
|
||||
|
||||
const oldRooms = this.rooms;
|
||||
const diff = arrayDiff(oldRooms, rooms);
|
||||
this.rooms = rooms;
|
||||
for (const oldRoom of diff.removed) {
|
||||
const state = this.states[oldRoom.roomId];
|
||||
if (!state) continue; // We likely just didn't have a badge (race condition)
|
||||
delete this.states[oldRoom.roomId];
|
||||
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
state.destroy();
|
||||
}
|
||||
for (const newRoom of diff.added) {
|
||||
const state = new TagSpecificNotificationState(newRoom, this.tagId);
|
||||
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
if (this.states[newRoom.roomId]) {
|
||||
// "Should never happen" disclaimer.
|
||||
console.warn("Overwriting notification state for room:", newRoom.roomId);
|
||||
this.states[newRoom.roomId].destroy();
|
||||
}
|
||||
this.states[newRoom.roomId] = state;
|
||||
}
|
||||
|
||||
this.calculateTotalState();
|
||||
}
|
||||
|
||||
public getForRoom(room: Room) {
|
||||
const state = this.states[room.roomId];
|
||||
if (!state) throw new Error("Unknown room for notification state");
|
||||
return state;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
for (const state of Object.values(this.states)) {
|
||||
state.destroy();
|
||||
}
|
||||
this.states = {};
|
||||
}
|
||||
|
||||
private onRoomNotificationStateUpdate = () => {
|
||||
this.calculateTotalState();
|
||||
};
|
||||
|
||||
private calculateTotalState() {
|
||||
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
|
||||
if (this.byTileCount) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._count = this.rooms.length;
|
||||
} else {
|
||||
this._count = 0;
|
||||
this._color = NotificationColor.None;
|
||||
for (const state of Object.values(this.states)) {
|
||||
this._count += state.count;
|
||||
this._color = Math.max(this.color, state.color);
|
||||
}
|
||||
}
|
||||
|
||||
// finally, publish an update if needed
|
||||
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,394 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
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, {createRef} from "react";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import RoomAvatar from '../avatars/RoomAvatar';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from "../../../index";
|
||||
import Analytics from "../../../Analytics";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import {_t} from "../../../languageHandler";
|
||||
|
||||
const MAX_ROOMS = 20;
|
||||
const MIN_ROOMS_BEFORE_ENABLED = 10;
|
||||
|
||||
// The threshold time in milliseconds to wait for an autojoined room to show up.
|
||||
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90 seconds
|
||||
|
||||
export default class RoomBreadcrumbs extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {rooms: [], enabled: false};
|
||||
|
||||
this.onAction = this.onAction.bind(this);
|
||||
this._dispatcherRef = null;
|
||||
|
||||
// The room IDs we're waiting to come down the Room handler and when we
|
||||
// started waiting for them. Used to track a room over an upgrade/autojoin.
|
||||
this._waitingRoomQueue = [/* { roomId, addedTs } */];
|
||||
|
||||
this._scroller = createRef();
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
const storedRooms = SettingsStore.getValue("breadcrumb_rooms");
|
||||
this._loadRoomIds(storedRooms || []);
|
||||
|
||||
this._settingWatchRef = SettingsStore.watchSetting("breadcrumb_rooms", null, this.onBreadcrumbsChanged);
|
||||
|
||||
this.setState({enabled: this._shouldEnable()});
|
||||
|
||||
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
|
||||
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
|
||||
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
|
||||
MatrixClientPeg.get().on("Room", this.onRoom);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
|
||||
SettingsStore.unwatchSetting(this._settingWatchRef);
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) {
|
||||
client.removeListener("Room.myMembership", this.onMyMembership);
|
||||
client.removeListener("Room.receipt", this.onRoomReceipt);
|
||||
client.removeListener("Room.timeline", this.onRoomTimeline);
|
||||
client.removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
client.removeListener("Room", this.onRoom);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const rooms = this.state.rooms.slice();
|
||||
|
||||
if (rooms.length) {
|
||||
const roomModel = rooms[0];
|
||||
if (!roomModel.animated) {
|
||||
roomModel.animated = true;
|
||||
setTimeout(() => this.setState({rooms}), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAction(payload) {
|
||||
switch (payload.action) {
|
||||
case 'view_room':
|
||||
if (payload.auto_join && !MatrixClientPeg.get().getRoom(payload.room_id)) {
|
||||
// Queue the room instead of pushing it immediately - we're probably just waiting
|
||||
// for a join to complete (ie: joining the upgraded room).
|
||||
this._waitingRoomQueue.push({roomId: payload.room_id, addedTs: (new Date).getTime()});
|
||||
break;
|
||||
}
|
||||
this._appendRoomId(payload.room_id);
|
||||
break;
|
||||
|
||||
// XXX: slight hack in order to zero the notification count when a room
|
||||
// is read. Copied from RoomTile
|
||||
case 'on_room_read': {
|
||||
const room = MatrixClientPeg.get().getRoom(payload.roomId);
|
||||
this._calculateRoomBadges(room, /*zero=*/true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMyMembership = (room, membership) => {
|
||||
if (membership === "leave" || membership === "ban") {
|
||||
const rooms = this.state.rooms.slice();
|
||||
const roomState = rooms.find((r) => r.room.roomId === room.roomId);
|
||||
if (roomState) {
|
||||
roomState.left = true;
|
||||
this.setState({rooms});
|
||||
}
|
||||
}
|
||||
this.onRoomMembershipChanged();
|
||||
};
|
||||
|
||||
onRoomReceipt = (event, room) => {
|
||||
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
|
||||
this._calculateRoomBadges(room);
|
||||
}
|
||||
};
|
||||
|
||||
onRoomTimeline = (event, room) => {
|
||||
if (!room) return; // Can be null for the notification timeline, etc.
|
||||
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
|
||||
this._calculateRoomBadges(room);
|
||||
}
|
||||
};
|
||||
|
||||
onEventDecrypted = (event) => {
|
||||
if (this.state.rooms.map(r => r.room.roomId).includes(event.getRoomId())) {
|
||||
this._calculateRoomBadges(MatrixClientPeg.get().getRoom(event.getRoomId()));
|
||||
}
|
||||
};
|
||||
|
||||
onBreadcrumbsChanged = (settingName, roomId, level, valueAtLevel, value) => {
|
||||
if (!value) return;
|
||||
|
||||
const currentState = this.state.rooms.map((r) => r.room.roomId);
|
||||
if (currentState.length === value.length) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < currentState.length; i++) {
|
||||
if (currentState[i] !== value[i]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
}
|
||||
|
||||
this._loadRoomIds(value);
|
||||
};
|
||||
|
||||
onRoomMembershipChanged = () => {
|
||||
if (!this.state.enabled && this._shouldEnable()) {
|
||||
this.setState({enabled: true});
|
||||
}
|
||||
};
|
||||
|
||||
onRoom = (room) => {
|
||||
// Always check for membership changes when we see new rooms
|
||||
this.onRoomMembershipChanged();
|
||||
|
||||
const waitingRoom = this._waitingRoomQueue.find(r => r.roomId === room.roomId);
|
||||
if (!waitingRoom) return;
|
||||
this._waitingRoomQueue.splice(this._waitingRoomQueue.indexOf(waitingRoom), 1);
|
||||
|
||||
const now = (new Date()).getTime();
|
||||
if ((now - waitingRoom.addedTs) > AUTOJOIN_WAIT_THRESHOLD_MS) return; // Too long ago.
|
||||
this._appendRoomId(room.roomId); // add the room we've been waiting for
|
||||
};
|
||||
|
||||
_shouldEnable() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const joinedRoomCount = client.getRooms().reduce((count, r) => {
|
||||
return count + (r.getMyMembership() === "join" ? 1 : 0);
|
||||
}, 0);
|
||||
return joinedRoomCount >= MIN_ROOMS_BEFORE_ENABLED;
|
||||
}
|
||||
|
||||
_loadRoomIds(roomIds) {
|
||||
if (!roomIds || roomIds.length <= 0) return; // Skip updates with no rooms
|
||||
|
||||
// If we're here, the list changed.
|
||||
const rooms = roomIds.map((r) => MatrixClientPeg.get().getRoom(r)).filter((r) => r).map((r) => {
|
||||
const badges = this._calculateBadgesForRoom(r) || {};
|
||||
return {
|
||||
room: r,
|
||||
animated: false,
|
||||
...badges,
|
||||
};
|
||||
});
|
||||
this.setState({
|
||||
rooms: rooms,
|
||||
});
|
||||
}
|
||||
|
||||
_calculateBadgesForRoom(room, zero=false) {
|
||||
if (!room) return null;
|
||||
|
||||
// Reset the notification variables for simplicity
|
||||
const roomModel = {
|
||||
redBadge: false,
|
||||
formattedCount: "0",
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
if (zero) return roomModel;
|
||||
|
||||
const notifState = RoomNotifs.getRoomNotifsState(room.roomId);
|
||||
if (RoomNotifs.MENTION_BADGE_STATES.includes(notifState)) {
|
||||
const highlightNotifs = RoomNotifs.getUnreadNotificationCount(room, 'highlight');
|
||||
const unreadNotifs = RoomNotifs.getUnreadNotificationCount(room);
|
||||
|
||||
const redBadge = highlightNotifs > 0;
|
||||
const greyBadge = redBadge || (unreadNotifs > 0 && RoomNotifs.BADGE_STATES.includes(notifState));
|
||||
|
||||
if (redBadge || greyBadge) {
|
||||
const notifCount = redBadge ? highlightNotifs : unreadNotifs;
|
||||
const limitedCount = FormattingUtils.formatCount(notifCount);
|
||||
|
||||
roomModel.redBadge = redBadge;
|
||||
roomModel.formattedCount = limitedCount;
|
||||
roomModel.showCount = true;
|
||||
}
|
||||
}
|
||||
|
||||
return roomModel;
|
||||
}
|
||||
|
||||
_calculateRoomBadges(room, zero=false) {
|
||||
if (!room) return;
|
||||
|
||||
const rooms = this.state.rooms.slice();
|
||||
const roomModel = rooms.find((r) => r.room.roomId === room.roomId);
|
||||
if (!roomModel) return; // No applicable room, so don't do math on it
|
||||
|
||||
const badges = this._calculateBadgesForRoom(room, zero);
|
||||
if (!badges) return; // No badges for some reason
|
||||
|
||||
Object.assign(roomModel, badges);
|
||||
this.setState({rooms});
|
||||
}
|
||||
|
||||
_appendRoomId(roomId) {
|
||||
let room = MatrixClientPeg.get().getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const rooms = this.state.rooms.slice();
|
||||
|
||||
// If the room is upgraded, use that room instead. We'll also splice out
|
||||
// any children of the room.
|
||||
const history = MatrixClientPeg.get().getRoomUpgradeHistory(roomId);
|
||||
if (history.length > 1) {
|
||||
room = history[history.length - 1]; // Last room is most recent
|
||||
|
||||
// Take out any room that isn't the most recent room
|
||||
for (let i = 0; i < history.length - 1; i++) {
|
||||
const idx = rooms.findIndex((r) => r.room.roomId === history[i].roomId);
|
||||
if (idx !== -1) rooms.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const existingIdx = rooms.findIndex((r) => r.room.roomId === room.roomId);
|
||||
if (existingIdx !== -1) {
|
||||
rooms.splice(existingIdx, 1);
|
||||
}
|
||||
|
||||
rooms.splice(0, 0, {room, animated: false});
|
||||
|
||||
if (rooms.length > MAX_ROOMS) {
|
||||
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
|
||||
}
|
||||
this.setState({rooms});
|
||||
|
||||
if (this._scroller.current) {
|
||||
this._scroller.current.moveToOrigin();
|
||||
}
|
||||
|
||||
// We don't track room aesthetics (badges, membership, etc) over the wire so we
|
||||
// don't need to do this elsewhere in the file. Just where we alter the room IDs
|
||||
// and their order.
|
||||
const roomIds = rooms.map((r) => r.room.roomId);
|
||||
if (roomIds.length > 0) {
|
||||
SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
|
||||
}
|
||||
}
|
||||
|
||||
_viewRoom(room, index) {
|
||||
Analytics.trackEvent("Breadcrumbs", "click_node", index);
|
||||
dis.dispatch({action: "view_room", room_id: room.roomId});
|
||||
}
|
||||
|
||||
_onMouseEnter(room) {
|
||||
this._onHover(room);
|
||||
}
|
||||
|
||||
_onMouseLeave(room) {
|
||||
this._onHover(null); // clear hover states
|
||||
}
|
||||
|
||||
_onHover(room) {
|
||||
const rooms = this.state.rooms.slice();
|
||||
for (const r of rooms) {
|
||||
r.hover = room && r.room.roomId === room.roomId;
|
||||
}
|
||||
this.setState({rooms});
|
||||
}
|
||||
|
||||
_isDmRoom(room) {
|
||||
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
return Boolean(dmRooms);
|
||||
}
|
||||
|
||||
render() {
|
||||
const Tooltip = sdk.getComponent('elements.Tooltip');
|
||||
const IndicatorScrollbar = sdk.getComponent('structures.IndicatorScrollbar');
|
||||
|
||||
// check for collapsed here and not at parent so we keep rooms in our state
|
||||
// when collapsing and expanding
|
||||
if (this.props.collapsed || !this.state.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rooms = this.state.rooms;
|
||||
const avatars = rooms.map((r, i) => {
|
||||
const isFirst = i === 0;
|
||||
const classes = classNames({
|
||||
"mx_RoomBreadcrumbs_crumb": true,
|
||||
"mx_RoomBreadcrumbs_preAnimate": isFirst && !r.animated,
|
||||
"mx_RoomBreadcrumbs_animate": isFirst,
|
||||
"mx_RoomBreadcrumbs_left": r.left,
|
||||
});
|
||||
|
||||
let tooltip = null;
|
||||
if (r.hover) {
|
||||
tooltip = <Tooltip label={r.room.name} />;
|
||||
}
|
||||
|
||||
let badge;
|
||||
if (r.showCount) {
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomTile_badge': true,
|
||||
'mx_RoomTile_badgeButton': true,
|
||||
'mx_RoomTile_badgeRed': r.redBadge,
|
||||
'mx_RoomTile_badgeUnread': !r.redBadge,
|
||||
});
|
||||
|
||||
badge = <div className={badgeClasses}>{r.formattedCount}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
className={classes}
|
||||
key={r.room.roomId}
|
||||
onClick={() => this._viewRoom(r.room, i)}
|
||||
onMouseEnter={() => this._onMouseEnter(r.room)}
|
||||
onMouseLeave={() => this._onMouseLeave(r.room)}
|
||||
aria-label={_t("Room %(name)s", {name: r.room.name})}
|
||||
>
|
||||
<RoomAvatar room={r.room} width={32} height={32} />
|
||||
{badge}
|
||||
{tooltip}
|
||||
</AccessibleButton>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div role="toolbar" aria-label={_t("Recent rooms")}>
|
||||
<IndicatorScrollbar
|
||||
ref={this._scroller}
|
||||
className="mx_RoomBreadcrumbs"
|
||||
trackHorizontalOverflow={true}
|
||||
verticalScrollsHorizontally={true}
|
||||
>
|
||||
{ avatars }
|
||||
</IndicatorScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -16,22 +16,17 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import Analytics from "../../../Analytics";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
@ -47,7 +42,7 @@ interface IState {
|
|||
skipFirst: boolean;
|
||||
}
|
||||
|
||||
export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState> {
|
||||
export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState> {
|
||||
private isMounted = true;
|
||||
|
||||
constructor(props: IProps) {
|
||||
|
@ -86,17 +81,26 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
|
|||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// TODO: Decorate crumbs with icons
|
||||
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
|
||||
const roomTags = RoomListStore.instance.getTagsForRoom(r);
|
||||
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_RoomBreadcrumbs2_crumb"
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_RoomBreadcrumbs_crumb"
|
||||
key={r.roomId}
|
||||
onClick={() => this.viewRoom(r, i)}
|
||||
aria-label={_t("Room %(name)s", {name: r.name})}
|
||||
title={r.name}
|
||||
tooltipClassName="mx_RoomBreadcrumbs_Tooltip"
|
||||
>
|
||||
<RoomAvatar room={r} width={32} height={32}/>
|
||||
</AccessibleButton>
|
||||
<DecoratedRoomAvatar
|
||||
room={r}
|
||||
avatarSize={32}
|
||||
tag={roomTag}
|
||||
displayBadge={true}
|
||||
forceCount={true}
|
||||
/>
|
||||
</RovingAccessibleTooltipButton>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -105,17 +109,17 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
|
|||
return (
|
||||
<CSSTransition
|
||||
appear={true} in={this.state.doAnimation} timeout={640}
|
||||
classNames='mx_RoomBreadcrumbs2'
|
||||
classNames='mx_RoomBreadcrumbs'
|
||||
>
|
||||
<div className='mx_RoomBreadcrumbs2'>
|
||||
<Toolbar className='mx_RoomBreadcrumbs'>
|
||||
{tiles.slice(this.state.skipFirst ? 1 : 0)}
|
||||
</div>
|
||||
</Toolbar>
|
||||
</CSSTransition>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='mx_RoomBreadcrumbs2'>
|
||||
<div className="mx_RoomBreadcrumbs2_placeholder">
|
||||
<div className='mx_RoomBreadcrumbs'>
|
||||
<div className="mx_RoomBreadcrumbs_placeholder">
|
||||
{_t("No recently visited rooms")}
|
||||
</div>
|
||||
</div>
|
|
@ -26,14 +26,14 @@ import Modal from "../../../Modal";
|
|||
import RateLimitedFunc from '../../../ratelimitedfunc';
|
||||
|
||||
import { linkifyElement } from '../../../HtmlUtils';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import ManageIntegsButton from '../elements/ManageIntegsButton';
|
||||
import {CancelButton} from './SimpleRoomHeader';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
import E2EIcon from './E2EIcon';
|
||||
import InviteOnlyIcon from './InviteOnlyIcon';
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomHeader',
|
||||
|
@ -152,26 +152,11 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
|
||||
|
||||
let searchStatus = null;
|
||||
let cancelButton = null;
|
||||
let settingsButton = null;
|
||||
let pinnedEventsButton = null;
|
||||
|
||||
const e2eIcon = this.props.e2eStatus ?
|
||||
<E2EIcon status={this.props.e2eStatus} /> :
|
||||
undefined;
|
||||
|
||||
const dmUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
|
||||
const joinRules = this.props.room && this.props.room.currentState.getStateEvents("m.room.join_rules", "");
|
||||
const joinRule = joinRules && joinRules.getContent().join_rule;
|
||||
let privateIcon;
|
||||
// Don't show an invite-only icon for DMs. Users know they're invite-only.
|
||||
if (!dmUserId && joinRule === "invite") {
|
||||
privateIcon = <InviteOnlyIcon />;
|
||||
}
|
||||
|
||||
if (this.props.onCancelClick) {
|
||||
cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
|
||||
}
|
||||
|
@ -221,24 +206,24 @@ export default createReactClass({
|
|||
}
|
||||
const topicElement =
|
||||
<div className="mx_RoomHeader_topic" ref={this._topic} title={topic} dir="auto">{ topic }</div>;
|
||||
const avatarSize = 28;
|
||||
|
||||
let roomAvatar;
|
||||
if (this.props.room) {
|
||||
roomAvatar = (<RoomAvatar
|
||||
roomAvatar = <DecoratedRoomAvatar
|
||||
room={this.props.room}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
avatarSize={32}
|
||||
tag={DefaultTagID.Untagged} // to apply room publicity badging
|
||||
oobData={this.props.oobData}
|
||||
viewAvatarOnClick={true} />);
|
||||
viewAvatarOnClick={true}
|
||||
/>;
|
||||
}
|
||||
|
||||
if (this.props.onSettingsClick) {
|
||||
settingsButton =
|
||||
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_settingsButton"
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_settingsButton"
|
||||
onClick={this.props.onSettingsClick}
|
||||
title={_t("Settings")}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
title={_t("Settings")} />;
|
||||
}
|
||||
|
||||
if (this.props.onPinnedClick && SettingsStore.isFeatureEnabled('feature_pinning')) {
|
||||
|
@ -250,55 +235,45 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
pinnedEventsButton =
|
||||
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
|
||||
onClick={this.props.onPinnedClick} title={_t("Pinned Messages")}>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
|
||||
onClick={this.props.onPinnedClick}
|
||||
title={_t("Pinned Messages")}
|
||||
>
|
||||
{ pinsIndicator }
|
||||
</AccessibleButton>;
|
||||
</AccessibleTooltipButton>;
|
||||
}
|
||||
|
||||
// var leave_button;
|
||||
// if (this.props.onLeaveClick) {
|
||||
// leave_button =
|
||||
// <div className="mx_RoomHeader_button" onClick={this.props.onLeaveClick} title="Leave room">
|
||||
// <TintableSvg src={require("../../../../res/img/leave.svg")} width="26" height="20"/>
|
||||
// </div>;
|
||||
// }
|
||||
|
||||
let forgetButton;
|
||||
if (this.props.onForgetClick) {
|
||||
forgetButton =
|
||||
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
|
||||
onClick={this.props.onForgetClick}
|
||||
title={_t("Forget room")}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
title={_t("Forget room")} />;
|
||||
}
|
||||
|
||||
let searchButton;
|
||||
if (this.props.onSearchClick && this.props.inRoom) {
|
||||
searchButton =
|
||||
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_searchButton"
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_searchButton"
|
||||
onClick={this.props.onSearchClick}
|
||||
title={_t("Search")}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
title={_t("Search")} />;
|
||||
}
|
||||
|
||||
let shareRoomButton;
|
||||
if (this.props.inRoom) {
|
||||
shareRoomButton =
|
||||
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_shareButton"
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_shareButton"
|
||||
onClick={this.onShareRoomClick}
|
||||
title={_t('Share room')}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
title={_t('Share room')} />;
|
||||
}
|
||||
|
||||
let manageIntegsButton;
|
||||
if (this.props.room && this.props.room.roomId && this.props.inRoom) {
|
||||
manageIntegsButton = <ManageIntegsButton
|
||||
room={this.props.room}
|
||||
/>;
|
||||
manageIntegsButton = <ManageIntegsButton room={this.props.room} />;
|
||||
}
|
||||
|
||||
const rightRow =
|
||||
|
@ -311,11 +286,13 @@ export default createReactClass({
|
|||
{ searchButton }
|
||||
</div>;
|
||||
|
||||
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
|
||||
|
||||
return (
|
||||
<div className="mx_RoomHeader light-panel">
|
||||
<div className="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel">
|
||||
<div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div>
|
||||
{ privateIcon }
|
||||
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
|
||||
<div className="mx_RoomHeader_e2eIcon">{ e2eIcon }</div>
|
||||
{ name }
|
||||
{ topicElement }
|
||||
{ cancelButton }
|
||||
|
|
|
@ -1,853 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 Vector Creations Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import Timer from "../../../utils/Timer";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as utils from "matrix-js-sdk/src/utils";
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import rate_limited_func from "../../../ratelimitedfunc";
|
||||
import * as Rooms from '../../../Rooms';
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
import TagOrderStore from '../../../stores/TagOrderStore';
|
||||
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import RoomSubList from '../../structures/RoomSubList';
|
||||
import ResizeHandle from '../elements/ResizeHandle';
|
||||
import CallHandler from "../../../CallHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import * as sdk from "../../../index";
|
||||
import * as Receipt from "../../../utils/Receipt";
|
||||
import {Resizer} from '../../../resizer';
|
||||
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
||||
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
|
||||
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
import * as Unread from "../../../Unread";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import {TAG_DM} from "../../../stores/RoomListStore";
|
||||
|
||||
const HIDE_CONFERENCE_CHANS = true;
|
||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||
const HOVER_MOVE_TIMEOUT = 1000;
|
||||
|
||||
function labelForTagName(tagName) {
|
||||
if (tagName.startsWith('u.')) return tagName.slice(2);
|
||||
return tagName;
|
||||
}
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomList',
|
||||
|
||||
propTypes: {
|
||||
ConferenceHandler: PropTypes.any,
|
||||
collapsed: PropTypes.bool.isRequired,
|
||||
searchFilter: PropTypes.string,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
||||
this._hoverClearTimer = null;
|
||||
this._subListRefs = {
|
||||
// key => RoomSubList ref
|
||||
};
|
||||
|
||||
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
|
||||
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
|
||||
this.subListSizes = sizesJson ? JSON.parse(sizesJson) : {};
|
||||
this.collapsedState = collapsedJson ? JSON.parse(collapsedJson) : {};
|
||||
this._layoutSections = [];
|
||||
|
||||
const unfilteredOptions = {
|
||||
allowWhitespace: false,
|
||||
handleHeight: 1,
|
||||
};
|
||||
this._unfilteredlayout = new Layout((key, size) => {
|
||||
const subList = this._subListRefs[key];
|
||||
if (subList) {
|
||||
subList.setHeight(size);
|
||||
}
|
||||
// update overflow indicators
|
||||
this._checkSubListsOverflow();
|
||||
// don't store height for collapsed sublists
|
||||
if (!this.collapsedState[key]) {
|
||||
this.subListSizes[key] = size;
|
||||
window.localStorage.setItem("mx_roomlist_sizes",
|
||||
JSON.stringify(this.subListSizes));
|
||||
}
|
||||
}, this.subListSizes, this.collapsedState, unfilteredOptions);
|
||||
|
||||
this._filteredLayout = new Layout((key, size) => {
|
||||
const subList = this._subListRefs[key];
|
||||
if (subList) {
|
||||
subList.setHeight(size);
|
||||
}
|
||||
}, null, null, {
|
||||
allowWhitespace: false,
|
||||
handleHeight: 0,
|
||||
});
|
||||
|
||||
this._layout = this._unfilteredlayout;
|
||||
|
||||
return {
|
||||
isLoadingLeftRooms: false,
|
||||
totalRoomCount: null,
|
||||
lists: {},
|
||||
incomingCallTag: null,
|
||||
incomingCall: null,
|
||||
selectedTags: [],
|
||||
hover: false,
|
||||
customTags: CustomRoomTagStore.getTags(),
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, put this in the constructor.
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this.mounted = false;
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
cli.on("Room", this.onRoom);
|
||||
cli.on("deleteRoom", this.onDeleteRoom);
|
||||
cli.on("Room.receipt", this.onRoomReceipt);
|
||||
cli.on("RoomMember.name", this.onRoomMemberName);
|
||||
cli.on("Event.decrypted", this.onEventDecrypted);
|
||||
cli.on("accountData", this.onAccountData);
|
||||
cli.on("Group.myMembership", this._onGroupMyMembership);
|
||||
cli.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
const dmRoomMap = DMRoomMap.shared();
|
||||
// A map between tags which are group IDs and the room IDs of rooms that should be kept
|
||||
// in the room list when filtering by that tag.
|
||||
this._visibleRoomsForGroup = {
|
||||
// $groupId: [$roomId1, $roomId2, ...],
|
||||
};
|
||||
// All rooms that should be kept in the room list when filtering.
|
||||
// By default, show all rooms.
|
||||
this._visibleRooms = MatrixClientPeg.get().getVisibleRooms();
|
||||
|
||||
// Listen to updates to group data. RoomList cares about members and rooms in order
|
||||
// to filter the room list when group tags are selected.
|
||||
this._groupStoreToken = GroupStore.registerListener(null, () => {
|
||||
(TagOrderStore.getOrderedTags() || []).forEach((tag) => {
|
||||
if (tag[0] !== '+') {
|
||||
return;
|
||||
}
|
||||
// This group's rooms or members may have updated, update rooms for its tag
|
||||
this.updateVisibleRoomsForTag(dmRoomMap, tag);
|
||||
this.updateVisibleRooms();
|
||||
});
|
||||
});
|
||||
|
||||
this._tagStoreToken = TagOrderStore.addListener(() => {
|
||||
// Filters themselves have changed
|
||||
this.updateVisibleRooms();
|
||||
});
|
||||
|
||||
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
|
||||
this._delayedRefreshRoomList();
|
||||
});
|
||||
|
||||
|
||||
if (SettingsStore.isFeatureEnabled("feature_custom_tags")) {
|
||||
this._customTagStoreToken = CustomRoomTagStore.addListener(() => {
|
||||
this.setState({
|
||||
customTags: CustomRoomTagStore.getTags(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.refreshRoomList();
|
||||
|
||||
// order of the sublists
|
||||
//this.listOrder = [];
|
||||
|
||||
// loop count to stop a stack overflow if the user keeps waggling the
|
||||
// mouse for >30s in a row, or if running under mocha
|
||||
this._delayedRefreshRoomListLoopCount = 0;
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
const cfg = {
|
||||
getLayout: () => this._layout,
|
||||
};
|
||||
this.resizer = new Resizer(this.resizeContainer, Distributor, cfg);
|
||||
this.resizer.setClassNames({
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
});
|
||||
this._layout.update(
|
||||
this._layoutSections,
|
||||
this.resizeContainer && this.resizeContainer.offsetHeight,
|
||||
);
|
||||
this._checkSubListsOverflow();
|
||||
|
||||
this.resizer.attach();
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.on("leftPanelResized", this.onResize);
|
||||
}
|
||||
this.mounted = true;
|
||||
},
|
||||
|
||||
componentDidUpdate: function(prevProps) {
|
||||
let forceLayoutUpdate = false;
|
||||
this._repositionIncomingCallBox(undefined, false);
|
||||
if (!this.props.searchFilter && prevProps.searchFilter) {
|
||||
this._layout = this._unfilteredlayout;
|
||||
forceLayoutUpdate = true;
|
||||
} else if (this.props.searchFilter && !prevProps.searchFilter) {
|
||||
this._layout = this._filteredLayout;
|
||||
forceLayoutUpdate = true;
|
||||
}
|
||||
this._layout.update(
|
||||
this._layoutSections,
|
||||
this.resizeContainer && this.resizeContainer.clientHeight,
|
||||
forceLayoutUpdate,
|
||||
);
|
||||
this._checkSubListsOverflow();
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
switch (payload.action) {
|
||||
case 'view_tooltip':
|
||||
this.tooltip = payload.tooltip;
|
||||
break;
|
||||
case 'call_state':
|
||||
var call = CallHandler.getCall(payload.room_id);
|
||||
if (call && call.call_state === 'ringing') {
|
||||
this.setState({
|
||||
incomingCall: call,
|
||||
incomingCallTag: this.getTagNameForRoomId(payload.room_id),
|
||||
});
|
||||
this._repositionIncomingCallBox(undefined, true);
|
||||
} else {
|
||||
this.setState({
|
||||
incomingCall: null,
|
||||
incomingCallTag: null,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'view_room_delta': {
|
||||
const currentRoomId = RoomViewStore.getRoomId();
|
||||
const {
|
||||
"im.vector.fake.invite": inviteRooms,
|
||||
"m.favourite": favouriteRooms,
|
||||
[TAG_DM]: dmRooms,
|
||||
"im.vector.fake.recent": recentRooms,
|
||||
"m.lowpriority": lowPriorityRooms,
|
||||
"im.vector.fake.archived": historicalRooms,
|
||||
"m.server_notice": serverNoticeRooms,
|
||||
...tags
|
||||
} = this.state.lists;
|
||||
|
||||
const shownCustomTagRooms = Object.keys(tags).filter(tagName => {
|
||||
return (!this.state.customTags || this.state.customTags[tagName]) &&
|
||||
!tagName.match(STANDARD_TAGS_REGEX);
|
||||
}).map(tagName => tags[tagName]);
|
||||
|
||||
// this order matches the one when generating the room sublists below.
|
||||
let rooms = this._applySearchFilter([
|
||||
...inviteRooms,
|
||||
...favouriteRooms,
|
||||
...dmRooms,
|
||||
...recentRooms,
|
||||
...[].concat.apply([], shownCustomTagRooms), // eslint-disable-line prefer-spread
|
||||
...lowPriorityRooms,
|
||||
...historicalRooms,
|
||||
...serverNoticeRooms,
|
||||
], this.props.searchFilter);
|
||||
|
||||
if (payload.unread) {
|
||||
// filter to only notification rooms (and our current active room so we can index properly)
|
||||
rooms = rooms.filter(room => {
|
||||
return room.roomId === currentRoomId || Unread.doesRoomHaveUnreadMessages(room);
|
||||
});
|
||||
}
|
||||
|
||||
const currentIndex = rooms.findIndex(room => room.roomId === currentRoomId);
|
||||
// use slice to account for looping around the start
|
||||
const [room] = rooms.slice((currentIndex + payload.delta) % rooms.length);
|
||||
if (room) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.roomId,
|
||||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.mounted = false;
|
||||
|
||||
dis.unregister(this.dispatcherRef);
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Room", this.onRoom);
|
||||
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
|
||||
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
|
||||
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
|
||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
|
||||
MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.removeListener("leftPanelResized", this.onResize);
|
||||
}
|
||||
|
||||
|
||||
if (this._tagStoreToken) {
|
||||
this._tagStoreToken.remove();
|
||||
}
|
||||
|
||||
if (this._roomListStoreToken) {
|
||||
this._roomListStoreToken.remove();
|
||||
}
|
||||
if (this._customTagStoreToken) {
|
||||
this._customTagStoreToken.remove();
|
||||
}
|
||||
|
||||
// NB: GroupStore is not a Flux.Store
|
||||
if (this._groupStoreToken) {
|
||||
this._groupStoreToken.unregister();
|
||||
}
|
||||
|
||||
// cancel any pending calls to the rate_limited_funcs
|
||||
this._delayedRefreshRoomList.cancelPendingCall();
|
||||
},
|
||||
|
||||
|
||||
onResize: function() {
|
||||
if (this.mounted && this._layout && this.resizeContainer &&
|
||||
Array.isArray(this._layoutSections)
|
||||
) {
|
||||
this._layout.update(
|
||||
this._layoutSections,
|
||||
this.resizeContainer.offsetHeight,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
onRoom: function(room) {
|
||||
this.updateVisibleRooms();
|
||||
},
|
||||
|
||||
onRoomStateEvents: function(ev, state) {
|
||||
if (ev.getType() === "m.room.create" || ev.getType() === "m.room.tombstone") {
|
||||
this.updateVisibleRooms();
|
||||
}
|
||||
},
|
||||
|
||||
onDeleteRoom: function(roomId) {
|
||||
this.updateVisibleRooms();
|
||||
},
|
||||
|
||||
onArchivedHeaderClick: function(isHidden, scrollToPosition) {
|
||||
if (!isHidden) {
|
||||
const self = this;
|
||||
this.setState({ isLoadingLeftRooms: true });
|
||||
// we don't care about the response since it comes down via "Room"
|
||||
// events.
|
||||
MatrixClientPeg.get().syncLeftRooms().catch(function(err) {
|
||||
console.error("Failed to sync left rooms: %s", err);
|
||||
console.error(err);
|
||||
}).finally(function() {
|
||||
self.setState({ isLoadingLeftRooms: false });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onRoomReceipt: function(receiptEvent, room) {
|
||||
// because if we read a notification, it will affect notification count
|
||||
// only bother updating if there's a receipt from us
|
||||
if (Receipt.findReadReceiptFromUserId(receiptEvent, MatrixClientPeg.get().credentials.userId)) {
|
||||
this._delayedRefreshRoomList();
|
||||
}
|
||||
},
|
||||
|
||||
onRoomMemberName: function(ev, member) {
|
||||
this._delayedRefreshRoomList();
|
||||
},
|
||||
|
||||
onEventDecrypted: function(ev) {
|
||||
// An event being decrypted may mean we need to re-order the room list
|
||||
this._delayedRefreshRoomList();
|
||||
},
|
||||
|
||||
onAccountData: function(ev) {
|
||||
if (ev.getType() == 'm.direct') {
|
||||
this._delayedRefreshRoomList();
|
||||
}
|
||||
},
|
||||
|
||||
_onGroupMyMembership: function(group) {
|
||||
this.forceUpdate();
|
||||
},
|
||||
|
||||
onMouseMove: async function(ev) {
|
||||
if (!this._hoverClearTimer) {
|
||||
this.setState({hover: true});
|
||||
this._hoverClearTimer = new Timer(HOVER_MOVE_TIMEOUT);
|
||||
this._hoverClearTimer.start();
|
||||
let finished = true;
|
||||
try {
|
||||
await this._hoverClearTimer.finished();
|
||||
} catch (err) {
|
||||
finished = false;
|
||||
}
|
||||
this._hoverClearTimer = null;
|
||||
if (finished) {
|
||||
this.setState({hover: false});
|
||||
this._delayedRefreshRoomList();
|
||||
}
|
||||
} else {
|
||||
this._hoverClearTimer.restart();
|
||||
}
|
||||
},
|
||||
|
||||
onMouseLeave: function(ev) {
|
||||
if (this._hoverClearTimer) {
|
||||
this._hoverClearTimer.abort();
|
||||
this._hoverClearTimer = null;
|
||||
}
|
||||
this.setState({hover: false});
|
||||
|
||||
// Refresh the room list just in case the user missed something.
|
||||
this._delayedRefreshRoomList();
|
||||
},
|
||||
|
||||
_delayedRefreshRoomList: rate_limited_func(function() {
|
||||
this.refreshRoomList();
|
||||
}, 500),
|
||||
|
||||
// Update which rooms and users should appear in RoomList for a given group tag
|
||||
updateVisibleRoomsForTag: function(dmRoomMap, tag) {
|
||||
if (!this.mounted) return;
|
||||
// For now, only handle group tags
|
||||
if (tag[0] !== '+') return;
|
||||
|
||||
this._visibleRoomsForGroup[tag] = [];
|
||||
GroupStore.getGroupRooms(tag).forEach((room) => this._visibleRoomsForGroup[tag].push(room.roomId));
|
||||
GroupStore.getGroupMembers(tag).forEach((member) => {
|
||||
if (member.userId === MatrixClientPeg.get().credentials.userId) return;
|
||||
dmRoomMap.getDMRoomsForUserId(member.userId).forEach(
|
||||
(roomId) => this._visibleRoomsForGroup[tag].push(roomId),
|
||||
);
|
||||
});
|
||||
// TODO: Check if room has been tagged to the group by the user
|
||||
},
|
||||
|
||||
// Update which rooms and users should appear according to which tags are selected
|
||||
updateVisibleRooms: function() {
|
||||
const selectedTags = TagOrderStore.getSelectedTags();
|
||||
const visibleGroupRooms = [];
|
||||
selectedTags.forEach((tag) => {
|
||||
(this._visibleRoomsForGroup[tag] || []).forEach(
|
||||
(roomId) => visibleGroupRooms.push(roomId),
|
||||
);
|
||||
});
|
||||
|
||||
// If there are any tags selected, constrain the rooms listed to the
|
||||
// visible rooms as determined by visibleGroupRooms. Here, we
|
||||
// de-duplicate and filter out rooms that the client doesn't know
|
||||
// about (hence the Set and the null-guard on `room`).
|
||||
if (selectedTags.length > 0) {
|
||||
const roomSet = new Set();
|
||||
visibleGroupRooms.forEach((roomId) => {
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
if (room) {
|
||||
roomSet.add(room);
|
||||
}
|
||||
});
|
||||
this._visibleRooms = Array.from(roomSet);
|
||||
} else {
|
||||
// Show all rooms
|
||||
this._visibleRooms = MatrixClientPeg.get().getVisibleRooms();
|
||||
}
|
||||
this._delayedRefreshRoomList();
|
||||
},
|
||||
|
||||
refreshRoomList: function() {
|
||||
if (this.state.hover) {
|
||||
// Don't re-sort the list if we're hovering over the list
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: ideally we'd calculate this once at start, and then maintain
|
||||
// any changes to it incrementally, updating the appropriate sublists
|
||||
// as needed.
|
||||
// Alternatively we'd do something magical with Immutable.js or similar.
|
||||
const lists = this.getRoomLists();
|
||||
let totalRooms = 0;
|
||||
for (const l of Object.values(lists)) {
|
||||
totalRooms += l.length;
|
||||
}
|
||||
this.setState({
|
||||
lists,
|
||||
totalRoomCount: totalRooms,
|
||||
// Do this here so as to not render every time the selected tags
|
||||
// themselves change.
|
||||
selectedTags: TagOrderStore.getSelectedTags(),
|
||||
}, () => {
|
||||
// we don't need to restore any size here, do we?
|
||||
// i guess we could have triggered a new group to appear
|
||||
// that already an explicit size the last time it appeared ...
|
||||
this._checkSubListsOverflow();
|
||||
});
|
||||
|
||||
// this._lastRefreshRoomListTs = Date.now();
|
||||
},
|
||||
|
||||
getTagNameForRoomId: function(roomId) {
|
||||
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||
for (const tagName of Object.keys(lists)) {
|
||||
for (const room of lists[tagName]) {
|
||||
// Should be impossible, but guard anyways.
|
||||
if (!room) {
|
||||
continue;
|
||||
}
|
||||
const myUserId = MatrixClientPeg.get().getUserId();
|
||||
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, myUserId, this.props.ConferenceHandler)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (room.roomId === roomId) return tagName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
getRoomLists: function() {
|
||||
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||
|
||||
const filteredLists = {};
|
||||
|
||||
const isRoomVisible = {
|
||||
// $roomId: true,
|
||||
};
|
||||
|
||||
this._visibleRooms.forEach((r) => {
|
||||
isRoomVisible[r.roomId] = true;
|
||||
});
|
||||
|
||||
Object.keys(lists).forEach((tagName) => {
|
||||
const filteredRooms = lists[tagName].filter((taggedRoom) => {
|
||||
// Somewhat impossible, but guard against it anyway
|
||||
if (!taggedRoom) {
|
||||
return;
|
||||
}
|
||||
const myUserId = MatrixClientPeg.get().getUserId();
|
||||
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(taggedRoom, myUserId, this.props.ConferenceHandler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Boolean(isRoomVisible[taggedRoom.roomId]);
|
||||
});
|
||||
|
||||
if (filteredRooms.length > 0 || tagName.match(STANDARD_TAGS_REGEX)) {
|
||||
filteredLists[tagName] = filteredRooms;
|
||||
}
|
||||
});
|
||||
|
||||
return filteredLists;
|
||||
},
|
||||
|
||||
_getScrollNode: function() {
|
||||
if (!this.mounted) return null;
|
||||
const panel = ReactDOM.findDOMNode(this);
|
||||
if (!panel) return null;
|
||||
|
||||
if (panel.classList.contains('gm-prevented')) {
|
||||
return panel;
|
||||
} else {
|
||||
return panel.children[2]; // XXX: Fragile!
|
||||
}
|
||||
},
|
||||
|
||||
_whenScrolling: function(e) {
|
||||
this._hideTooltip(e);
|
||||
this._repositionIncomingCallBox(e, false);
|
||||
},
|
||||
|
||||
_hideTooltip: function(e) {
|
||||
// Hide tooltip when scrolling, as we'll no longer be over the one we were on
|
||||
if (this.tooltip && this.tooltip.style.display !== "none") {
|
||||
this.tooltip.style.display = "none";
|
||||
}
|
||||
},
|
||||
|
||||
_repositionIncomingCallBox: function(e, firstTime) {
|
||||
const incomingCallBox = document.getElementById("incomingCallBox");
|
||||
if (incomingCallBox && incomingCallBox.parentElement) {
|
||||
const scrollArea = this._getScrollNode();
|
||||
if (!scrollArea) return;
|
||||
// Use the offset of the top of the scroll area from the window
|
||||
// as this is used to calculate the CSS fixed top position for the stickies
|
||||
const scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset;
|
||||
// Use the offset of the top of the component from the window
|
||||
// as this is used to calculate the CSS fixed top position for the stickies
|
||||
const scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
|
||||
|
||||
let top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset);
|
||||
// Make sure we don't go too far up, if the headers aren't sticky
|
||||
top = (top < scrollAreaOffset) ? scrollAreaOffset : top;
|
||||
// make sure we don't go too far down, if the headers aren't sticky
|
||||
const bottomMargin = scrollAreaOffset + (scrollAreaHeight - 45);
|
||||
top = (top > bottomMargin) ? bottomMargin : top;
|
||||
|
||||
incomingCallBox.style.top = top + "px";
|
||||
incomingCallBox.style.left = scrollArea.offsetLeft + scrollArea.offsetWidth + 12 + "px";
|
||||
}
|
||||
},
|
||||
|
||||
_makeGroupInviteTiles(filter) {
|
||||
const ret = [];
|
||||
const lcFilter = filter && filter.toLowerCase();
|
||||
|
||||
const GroupInviteTile = sdk.getComponent('groups.GroupInviteTile');
|
||||
for (const group of MatrixClientPeg.get().getGroups()) {
|
||||
const {groupId, name, myMembership} = group;
|
||||
// filter to only groups in invite state and group_id starts with filter or group name includes it
|
||||
if (myMembership !== 'invite') continue;
|
||||
if (lcFilter && !groupId.toLowerCase().startsWith(lcFilter) &&
|
||||
!(name && name.toLowerCase().includes(lcFilter))) continue;
|
||||
ret.push(<GroupInviteTile key={groupId} group={group} collapsed={this.props.collapsed} />);
|
||||
}
|
||||
|
||||
return ret;
|
||||
},
|
||||
|
||||
_applySearchFilter: function(list, filter) {
|
||||
if (filter === "") return list;
|
||||
const lcFilter = filter.toLowerCase();
|
||||
// apply toLowerCase before and after removeHiddenChars because different rules get applied
|
||||
// e.g M -> M but m -> n, yet some unicode homoglyphs come out as uppercase, e.g 𝚮 -> H
|
||||
const fuzzyFilter = utils.removeHiddenChars(lcFilter).toLowerCase();
|
||||
// case insensitive if room name includes filter,
|
||||
// or if starts with `#` and one of room's aliases starts with filter
|
||||
return list.filter((room) => {
|
||||
if (filter[0] === "#") {
|
||||
if (room.getCanonicalAlias() && room.getCanonicalAlias().toLowerCase().startsWith(lcFilter)) {
|
||||
return true;
|
||||
}
|
||||
if (room.getAltAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return room.name && utils.removeHiddenChars(room.name.toLowerCase()).toLowerCase().includes(fuzzyFilter);
|
||||
});
|
||||
},
|
||||
|
||||
_handleCollapsedState: function(key, collapsed) {
|
||||
// persist collapsed state
|
||||
this.collapsedState[key] = collapsed;
|
||||
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.collapsedState));
|
||||
// load the persisted size configuration of the expanded sub list
|
||||
if (collapsed) {
|
||||
this._layout.collapseSection(key);
|
||||
} else {
|
||||
this._layout.expandSection(key, this.subListSizes[key]);
|
||||
}
|
||||
// check overflow, as sub lists sizes have changed
|
||||
// important this happens after calling resize above
|
||||
this._checkSubListsOverflow();
|
||||
},
|
||||
|
||||
// check overflow for scroll indicator gradient
|
||||
_checkSubListsOverflow() {
|
||||
Object.values(this._subListRefs).forEach(l => l.checkOverflow());
|
||||
},
|
||||
|
||||
_subListRef: function(key, ref) {
|
||||
if (!ref) {
|
||||
delete this._subListRefs[key];
|
||||
} else {
|
||||
this._subListRefs[key] = ref;
|
||||
}
|
||||
},
|
||||
|
||||
_mapSubListProps: function(subListsProps) {
|
||||
this._layoutSections = [];
|
||||
const defaultProps = {
|
||||
collapsed: this.props.collapsed,
|
||||
isFiltered: !!this.props.searchFilter,
|
||||
};
|
||||
|
||||
subListsProps.forEach((p) => {
|
||||
p.list = this._applySearchFilter(p.list, this.props.searchFilter);
|
||||
});
|
||||
|
||||
subListsProps = subListsProps.filter((props => {
|
||||
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
|
||||
return len !== 0 || props.onAddRoom;
|
||||
}));
|
||||
|
||||
return subListsProps.reduce((components, props, i) => {
|
||||
props = {...defaultProps, ...props};
|
||||
const isLast = i === subListsProps.length - 1;
|
||||
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
|
||||
const {key, label, onHeaderClick, ...otherProps} = props;
|
||||
const chosenKey = key || label;
|
||||
const onSubListHeaderClick = (collapsed) => {
|
||||
this._handleCollapsedState(chosenKey, collapsed);
|
||||
if (onHeaderClick) {
|
||||
onHeaderClick(collapsed);
|
||||
}
|
||||
};
|
||||
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
|
||||
this._layoutSections.push({
|
||||
id: chosenKey,
|
||||
count: len,
|
||||
});
|
||||
const subList = (<RoomSubList
|
||||
ref={this._subListRef.bind(this, chosenKey)}
|
||||
startAsHidden={startAsHidden}
|
||||
forceExpand={!!this.props.searchFilter}
|
||||
onHeaderClick={onSubListHeaderClick}
|
||||
key={chosenKey}
|
||||
label={label}
|
||||
{...otherProps} />);
|
||||
|
||||
if (!isLast) {
|
||||
return components.concat(
|
||||
subList,
|
||||
<ResizeHandle key={chosenKey+"-resizer"} vertical={true} id={chosenKey} />
|
||||
);
|
||||
} else {
|
||||
return components.concat(subList);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
|
||||
_collectResizeContainer: function(el) {
|
||||
this.resizeContainer = el;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const incomingCallIfTaggedAs = (tagName) => {
|
||||
if (!this.state.incomingCall) return null;
|
||||
if (this.state.incomingCallTag !== tagName) return null;
|
||||
return this.state.incomingCall;
|
||||
};
|
||||
|
||||
let subLists = [
|
||||
{
|
||||
list: [],
|
||||
extraTiles: this._makeGroupInviteTiles(this.props.searchFilter),
|
||||
label: _t('Community Invites'),
|
||||
isInvite: true,
|
||||
},
|
||||
{
|
||||
list: this.state.lists['im.vector.fake.invite'],
|
||||
label: _t('Invites'),
|
||||
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
|
||||
isInvite: true,
|
||||
},
|
||||
{
|
||||
list: this.state.lists['m.favourite'],
|
||||
label: _t('Favourites'),
|
||||
tagName: "m.favourite",
|
||||
incomingCall: incomingCallIfTaggedAs('m.favourite'),
|
||||
},
|
||||
{
|
||||
list: this.state.lists[DefaultTagID.DM],
|
||||
label: _t('Direct Messages'),
|
||||
tagName: DefaultTagID.DM,
|
||||
incomingCall: incomingCallIfTaggedAs(DefaultTagID.DM),
|
||||
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
|
||||
addRoomLabel: _t("Start chat"),
|
||||
},
|
||||
{
|
||||
list: this.state.lists['im.vector.fake.recent'],
|
||||
label: _t('Rooms'),
|
||||
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
|
||||
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
|
||||
addRoomLabel: _t("Create room"),
|
||||
},
|
||||
];
|
||||
const tagSubLists = Object.keys(this.state.lists)
|
||||
.filter((tagName) => {
|
||||
return (!this.state.customTags || this.state.customTags[tagName]) &&
|
||||
!tagName.match(STANDARD_TAGS_REGEX);
|
||||
}).map((tagName) => {
|
||||
return {
|
||||
list: this.state.lists[tagName],
|
||||
key: tagName,
|
||||
label: labelForTagName(tagName),
|
||||
tagName: tagName,
|
||||
incomingCall: incomingCallIfTaggedAs(tagName),
|
||||
};
|
||||
});
|
||||
subLists = subLists.concat(tagSubLists);
|
||||
subLists = subLists.concat([
|
||||
{
|
||||
list: this.state.lists['m.lowpriority'],
|
||||
label: _t('Low priority'),
|
||||
tagName: "m.lowpriority",
|
||||
incomingCall: incomingCallIfTaggedAs('m.lowpriority'),
|
||||
},
|
||||
{
|
||||
list: this.state.lists['im.vector.fake.archived'],
|
||||
label: _t('Historical'),
|
||||
incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'),
|
||||
startAsHidden: true,
|
||||
showSpinner: this.state.isLoadingLeftRooms,
|
||||
onHeaderClick: this.onArchivedHeaderClick,
|
||||
},
|
||||
{
|
||||
list: this.state.lists['m.server_notice'],
|
||||
label: _t('System Alerts'),
|
||||
tagName: "m.lowpriority",
|
||||
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
|
||||
},
|
||||
]);
|
||||
|
||||
const subListComponents = this._mapSubListProps(subLists);
|
||||
|
||||
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, onKeyDown, ...props} = this.props; // eslint-disable-line
|
||||
return (
|
||||
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
|
||||
{({onKeyDownHandler}) => <div
|
||||
{...props}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
ref={this._collectResizeContainer}
|
||||
className="mx_RoomList"
|
||||
role="tree"
|
||||
aria-label={_t("Rooms")}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex="-1"
|
||||
onMouseMove={this.onMouseMove}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{ subListComponents }
|
||||
</div> }
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -17,31 +17,36 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Dispatcher } from "flux";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStore2 } from "../../../stores/room-list/RoomListStore2";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { Dispatcher } from "flux";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import RoomSublist2 from "./RoomSublist2";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import RoomSublist from "./RoomSublist";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import GroupAvatar from "../avatars/GroupAvatar";
|
||||
import TemporaryTile from "./TemporaryTile";
|
||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
interface IProps {
|
||||
onKeyDown: (ev: React.KeyboardEvent) => void;
|
||||
onFocus: (ev: React.FocusEvent) => void;
|
||||
onBlur: (ev: React.FocusEvent) => void;
|
||||
onResize: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
collapsed: boolean;
|
||||
searchFilter: string;
|
||||
|
@ -50,12 +55,9 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
sublists: ITagMap;
|
||||
layouts: Map<TagID, ListLayout>;
|
||||
}
|
||||
|
||||
const TAG_ORDER: TagID[] = [
|
||||
// -- Community Invites Placeholder --
|
||||
|
||||
DefaultTagID.Invite,
|
||||
DefaultTagID.Favourite,
|
||||
DefaultTagID.DM,
|
||||
|
@ -67,7 +69,6 @@ const TAG_ORDER: TagID[] = [
|
|||
DefaultTagID.ServerNotice,
|
||||
DefaultTagID.Archived,
|
||||
];
|
||||
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
|
||||
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
|
||||
const ALWAYS_VISIBLE_TAGS: TagID[] = [
|
||||
DefaultTagID.DM,
|
||||
|
@ -120,6 +121,8 @@ const TAG_AESTHETICS: {
|
|||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
},
|
||||
|
||||
// TODO: Replace with archived view: https://github.com/vector-im/riot-web/issues/14038
|
||||
[DefaultTagID.Archived]: {
|
||||
sectionLabel: _td("Historical"),
|
||||
isInvite: false,
|
||||
|
@ -127,16 +130,18 @@ const TAG_AESTHETICS: {
|
|||
},
|
||||
};
|
||||
|
||||
export default class RoomList2 extends React.Component<IProps, IState> {
|
||||
export default class RoomList extends React.Component<IProps, IState> {
|
||||
private searchFilter: NameFilterCondition = new NameFilterCondition();
|
||||
private dispatcherRef;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
sublists: {},
|
||||
layouts: new Map<TagID, ListLayout>(),
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||
|
@ -155,16 +160,97 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store: RoomListStore2) => {
|
||||
const newLists = store.orderedLists;
|
||||
console.log("new lists", newLists);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
|
||||
this.updateLists(); // trigger the first update
|
||||
}
|
||||
|
||||
const layoutMap = new Map<TagID, ListLayout>();
|
||||
for (const tagId of Object.keys(newLists)) {
|
||||
layoutMap.set(tagId, new ListLayout(tagId));
|
||||
public componentWillUnmount() {
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === Action.ViewRoomDelta) {
|
||||
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
|
||||
const currentRoomId = RoomViewStore.getRoomId();
|
||||
const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
|
||||
if (room) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.roomId,
|
||||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private getRoomDelta = (roomId: string, delta: number, unread = false) => {
|
||||
const lists = RoomListStore.instance.orderedLists;
|
||||
let rooms: Room = [];
|
||||
TAG_ORDER.forEach(t => {
|
||||
let listRooms = lists[t];
|
||||
|
||||
if (unread) {
|
||||
// filter to only notification rooms (and our current active room so we can index properly)
|
||||
listRooms = listRooms.filter(r => {
|
||||
const state = RoomNotificationStateStore.instance.getRoomState(r, t);
|
||||
return state.room.roomId === roomId || state.isUnread;
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({sublists: newLists, layouts: layoutMap});
|
||||
rooms.push(...listRooms);
|
||||
});
|
||||
|
||||
const currentIndex = rooms.findIndex(r => r.roomId === roomId);
|
||||
// use slice to account for looping around the start
|
||||
const [room] = rooms.slice((currentIndex + delta) % rooms.length);
|
||||
return room;
|
||||
};
|
||||
|
||||
private updateLists = () => {
|
||||
const newLists = RoomListStore.instance.orderedLists;
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
|
||||
console.log("new lists", newLists);
|
||||
}
|
||||
|
||||
this.setState({sublists: newLists}, () => {
|
||||
this.props.onResize();
|
||||
});
|
||||
};
|
||||
|
||||
private renderCommunityInvites(): React.ReactElement[] {
|
||||
// TODO: Put community invites in a more sensible place (not in the room list)
|
||||
// See https://github.com/vector-im/riot-web/issues/14456
|
||||
return MatrixClientPeg.get().getGroups().filter(g => {
|
||||
if (g.myMembership !== 'invite') return false;
|
||||
return !this.searchFilter || this.searchFilter.matches(g.name || "");
|
||||
}).map(g => {
|
||||
const avatar = (
|
||||
<GroupAvatar
|
||||
groupId={g.groupId}
|
||||
groupName={g.name}
|
||||
groupAvatarUrl={g.avatarUrl}
|
||||
width={32} height={32} resizeMethod='crop'
|
||||
/>
|
||||
);
|
||||
const openGroup = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'view_group',
|
||||
group_id: g.groupId,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<TemporaryTile
|
||||
isMinimized={this.props.isMinimized}
|
||||
isSelected={false}
|
||||
displayName={g.name}
|
||||
avatar={avatar}
|
||||
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
|
||||
onClick={openGroup}
|
||||
key={`temporaryGroupTile_${g.groupId}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -172,17 +258,15 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
const components: React.ReactElement[] = [];
|
||||
|
||||
for (const orderedTagId of TAG_ORDER) {
|
||||
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
|
||||
// Populate community invites if we have the chance
|
||||
// TODO
|
||||
}
|
||||
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
|
||||
// Populate custom tags if needed
|
||||
// TODO
|
||||
// TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091
|
||||
}
|
||||
|
||||
const orderedRooms = this.state.sublists[orderedTagId] || [];
|
||||
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
|
||||
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
|
||||
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
|
||||
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
|
||||
continue; // skip tag - not needed
|
||||
}
|
||||
|
||||
|
@ -191,7 +275,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
|
||||
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
||||
components.push(
|
||||
<RoomSublist2
|
||||
<RoomSublist
|
||||
key={`sublist-${orderedTagId}`}
|
||||
tagId={orderedTagId}
|
||||
forRooms={true}
|
||||
|
@ -200,9 +284,10 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
label={_t(aesthetics.sectionLabel)}
|
||||
onAddRoom={onAddRoomFn}
|
||||
addRoomLabel={aesthetics.addRoomLabel}
|
||||
isInvite={aesthetics.isInvite}
|
||||
layout={this.state.layouts.get(orderedTagId)}
|
||||
isMinimized={this.props.isMinimized}
|
||||
onResize={this.props.onResize}
|
||||
extraBadTilesThatShouldntExist={extraTiles}
|
||||
isFiltered={!!this.searchFilter.search}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -219,12 +304,9 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
onFocus={this.props.onFocus}
|
||||
onBlur={this.props.onBlur}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
className="mx_RoomList2"
|
||||
className="mx_RoomList"
|
||||
role="tree"
|
||||
aria-label={_t("Rooms")}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>{sublists}</div>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
|
@ -24,6 +24,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
|||
import dis from '../../../dispatcher/dispatcher';
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import IdentityAuthClient from '../../../IdentityAuthClient';
|
||||
|
||||
const MessageCase = Object.freeze({
|
||||
|
@ -282,6 +283,7 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
|
@ -398,7 +400,8 @@ export default createReactClass({
|
|||
);
|
||||
subTitle = _t(
|
||||
"Link this email with your account in Settings to receive invites " +
|
||||
"directly in Riot.",
|
||||
"directly in %(brand)s.",
|
||||
{ brand },
|
||||
);
|
||||
primaryActionLabel = _t("Join the discussion");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
|
@ -413,7 +416,8 @@ export default createReactClass({
|
|||
},
|
||||
);
|
||||
subTitle = _t(
|
||||
"Use an identity server in Settings to receive invites directly in Riot.",
|
||||
"Use an identity server in Settings to receive invites directly in %(brand)s.",
|
||||
{ brand },
|
||||
);
|
||||
primaryActionLabel = _t("Join the discussion");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
|
@ -428,7 +432,8 @@ export default createReactClass({
|
|||
},
|
||||
);
|
||||
subTitle = _t(
|
||||
"Share this email in Settings to receive invites directly in Riot.",
|
||||
"Share this email in Settings to receive invites directly in %(brand)s.",
|
||||
{ brand },
|
||||
);
|
||||
primaryActionLabel = _t("Join the discussion");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
|
|
739
src/components/views/rooms/RoomSublist.tsx
Normal file
739
src/components/views/rooms/RoomSublist.tsx
Normal file
|
@ -0,0 +1,739 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 Vector Creations Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {createRef} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from 'classnames';
|
||||
import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import RoomTile from "./RoomTile";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
ContextMenuTooltipButton,
|
||||
StyledMenuItemCheckbox,
|
||||
StyledMenuItemRadio,
|
||||
} from "../../structures/ContextMenu";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { Enable, Resizable } from "re-resizable";
|
||||
import { Direction } from "re-resizable/lib/resizer";
|
||||
import { polyfillTouchEvent } from "../../../@types/polyfill";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
|
||||
|
||||
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
|
||||
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
|
||||
export const HEADER_HEIGHT = 32; // As defined by CSS
|
||||
|
||||
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
|
||||
|
||||
// HACK: We really shouldn't have to do this.
|
||||
polyfillTouchEvent();
|
||||
|
||||
interface IProps {
|
||||
forRooms: boolean;
|
||||
rooms?: Room[];
|
||||
startAsHidden: boolean;
|
||||
label: string;
|
||||
onAddRoom?: () => void;
|
||||
addRoomLabel: string;
|
||||
isMinimized: boolean;
|
||||
tagId: TagID;
|
||||
onResize: () => void;
|
||||
isFiltered: boolean;
|
||||
|
||||
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
|
||||
// You should feel bad if you use this.
|
||||
extraBadTilesThatShouldntExist?: React.ReactElement[];
|
||||
|
||||
// TODO: Account for https://github.com/vector-im/riot-web/issues/14179
|
||||
}
|
||||
|
||||
// TODO: Use re-resizer's NumberSize when it is exposed as the type
|
||||
interface ResizeDelta {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
notificationState: ListNotificationState;
|
||||
contextMenuPosition: PartialDOMRect;
|
||||
isResizing: boolean;
|
||||
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default class RoomSublist extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef<HTMLDivElement>();
|
||||
private sublistRef = createRef<HTMLDivElement>();
|
||||
private dispatcherRef: string;
|
||||
private layout: ListLayout;
|
||||
private heightAtStart: number;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
|
||||
this.heightAtStart = 0;
|
||||
const height = this.calculateInitialHeight();
|
||||
this.state = {
|
||||
notificationState: RoomNotificationStateStore.instance.getListState(this.props.tagId),
|
||||
contextMenuPosition: null,
|
||||
isResizing: false,
|
||||
isExpanded: this.props.isFiltered ? this.props.isFiltered : !this.layout.isCollapsed,
|
||||
height,
|
||||
};
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
private calculateInitialHeight() {
|
||||
const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
|
||||
const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
|
||||
return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
|
||||
}
|
||||
|
||||
private get padding() {
|
||||
let padding = RESIZE_HANDLE_HEIGHT;
|
||||
// this is used for calculating the max height of the whole container,
|
||||
// and takes into account whether there should be room reserved for the show more/less button
|
||||
// when fully expanded. We can't rely purely on the layout's defaultVisible tile count
|
||||
// because there are conditions in which we need to know that the 'show more' button
|
||||
// is present while well under the default tile limit.
|
||||
const needsShowMore = this.numTiles > this.numVisibleTiles;
|
||||
|
||||
// ...but also check this or we'll miss if the section is expanded and we need a
|
||||
// 'show less'
|
||||
const needsShowLess = this.numTiles > this.layout.defaultVisibleTiles;
|
||||
|
||||
if (needsShowMore || needsShowLess) {
|
||||
padding += SHOW_N_BUTTON_HEIGHT;
|
||||
}
|
||||
return padding;
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
return RoomSublist.calcNumTiles(this.props);
|
||||
}
|
||||
|
||||
private static calcNumTiles(props) {
|
||||
return (props.rooms || []).length + (props.extraBadTilesThatShouldntExist || []).length;
|
||||
}
|
||||
|
||||
private get numVisibleTiles(): number {
|
||||
const nVisible = Math.ceil(this.layout.visibleTiles);
|
||||
return Math.min(nVisible, this.numTiles);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>) {
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
if (prevProps.isFiltered !== this.props.isFiltered) {
|
||||
if (this.props.isFiltered) {
|
||||
this.setState({isExpanded: true});
|
||||
} else {
|
||||
this.setState({isExpanded: !this.layout.isCollapsed});
|
||||
}
|
||||
}
|
||||
// as the rooms can come in one by one we need to reevaluate
|
||||
// the amount of available rooms to cap the amount of requested visible rooms by the layout
|
||||
if (RoomSublist.calcNumTiles(prevProps) !== this.numTiles) {
|
||||
this.setState({height: this.calculateInitialHeight()});
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.state.notificationState.destroy();
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) {
|
||||
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
|
||||
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
|
||||
setImmediate(() => {
|
||||
const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
|
||||
|
||||
if (!this.state.isExpanded && roomIndex > -1) {
|
||||
this.toggleCollapsed();
|
||||
}
|
||||
// extend the visible section to include the room if it is entirely invisible
|
||||
if (roomIndex >= this.numVisibleTiles) {
|
||||
this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
};
|
||||
|
||||
private applyHeightChange(newHeight: number) {
|
||||
const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
|
||||
this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
|
||||
}
|
||||
|
||||
private onResize = (
|
||||
e: MouseEvent | TouchEvent,
|
||||
travelDirection: Direction,
|
||||
refToElement: HTMLDivElement,
|
||||
delta: ResizeDelta,
|
||||
) => {
|
||||
const newHeight = this.heightAtStart + delta.height;
|
||||
this.applyHeightChange(newHeight);
|
||||
this.setState({height: newHeight});
|
||||
};
|
||||
|
||||
private onResizeStart = () => {
|
||||
this.heightAtStart = this.state.height;
|
||||
this.setState({isResizing: true});
|
||||
};
|
||||
|
||||
private onResizeStop = (
|
||||
e: MouseEvent | TouchEvent,
|
||||
travelDirection: Direction,
|
||||
refToElement: HTMLDivElement,
|
||||
delta: ResizeDelta,
|
||||
) => {
|
||||
const newHeight = this.heightAtStart + delta.height;
|
||||
this.applyHeightChange(newHeight);
|
||||
this.setState({isResizing: false, height: newHeight});
|
||||
};
|
||||
|
||||
private onShowAllClick = () => {
|
||||
// read number of visible tiles before we mutate it
|
||||
const numVisibleTiles = this.numVisibleTiles;
|
||||
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
|
||||
this.applyHeightChange(newHeight);
|
||||
this.setState({height: newHeight}, () => {
|
||||
// focus the top-most new room
|
||||
this.focusRoomTile(numVisibleTiles);
|
||||
});
|
||||
};
|
||||
|
||||
private onShowLessClick = () => {
|
||||
const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
|
||||
this.applyHeightChange(newHeight);
|
||||
this.setState({height: newHeight});
|
||||
};
|
||||
|
||||
private focusRoomTile = (index: number) => {
|
||||
if (!this.sublistRef.current) return;
|
||||
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile");
|
||||
const element = elements && elements[index];
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({contextMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
contextMenuPosition: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseMenu = () => {
|
||||
this.setState({contextMenuPosition: null});
|
||||
};
|
||||
|
||||
private onUnreadFirstChanged = async () => {
|
||||
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
|
||||
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
|
||||
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
|
||||
};
|
||||
|
||||
private onTagSortChanged = async (sort: SortAlgorithm) => {
|
||||
await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
|
||||
};
|
||||
|
||||
private onMessagePreviewChanged = () => {
|
||||
this.layout.showPreviews = !this.layout.showPreviews;
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private onBadgeClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
let room;
|
||||
if (this.props.tagId === DefaultTagID.Invite) {
|
||||
// switch to first room as that'll be the top of the list for the user
|
||||
room = this.props.rooms && this.props.rooms[0];
|
||||
} else {
|
||||
// find the first room with a count of the same colour as the badge count
|
||||
room = this.props.rooms.find((r: Room) => {
|
||||
const notifState = this.state.notificationState.getForRoom(r);
|
||||
return notifState.count > 0 && notifState.color === this.state.notificationState.color;
|
||||
});
|
||||
}
|
||||
|
||||
if (room) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.roomId,
|
||||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onHeaderClick = () => {
|
||||
const possibleSticky = this.headerButton.current.parentElement;
|
||||
const sublist = possibleSticky.parentElement.parentElement;
|
||||
const list = sublist.parentElement.parentElement;
|
||||
// the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky
|
||||
const isAtTop = list.scrollTop <= HEADER_HEIGHT;
|
||||
const isAtBottom = list.scrollTop >= list.scrollHeight - list.offsetHeight;
|
||||
const isStickyTop = possibleSticky.classList.contains('mx_RoomSublist_headerContainer_stickyTop');
|
||||
const isStickyBottom = possibleSticky.classList.contains('mx_RoomSublist_headerContainer_stickyBottom');
|
||||
|
||||
if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) {
|
||||
// is sticky - jump to list
|
||||
sublist.scrollIntoView({behavior: 'smooth'});
|
||||
} else {
|
||||
// on screen - toggle collapse
|
||||
const isExpanded = this.state.isExpanded;
|
||||
this.toggleCollapsed();
|
||||
// if the bottom list is collapsed then scroll it in so it doesn't expand off screen
|
||||
if (!isExpanded && isStickyBottom) {
|
||||
setImmediate(() => {
|
||||
sublist.scrollIntoView({behavior: 'smooth'});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private toggleCollapsed = () => {
|
||||
this.layout.isCollapsed = this.state.isExpanded;
|
||||
this.setState({isExpanded: !this.layout.isCollapsed});
|
||||
setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
|
||||
};
|
||||
|
||||
private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
if (this.state.isExpanded) {
|
||||
// On ARROW_LEFT collapse the room sublist if it isn't already
|
||||
this.toggleCollapsed();
|
||||
}
|
||||
break;
|
||||
case Key.ARROW_RIGHT: {
|
||||
ev.stopPropagation();
|
||||
if (!this.state.isExpanded) {
|
||||
// On ARROW_RIGHT expand the room sublist if it isn't already
|
||||
this.toggleCollapsed();
|
||||
} else if (this.sublistRef.current) {
|
||||
// otherwise focus the first room
|
||||
const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement;
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
// On ARROW_LEFT go to the sublist header
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
this.headerButton.current.focus();
|
||||
break;
|
||||
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
|
||||
case Key.ARROW_RIGHT:
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
private renderVisibleTiles(): React.ReactElement[] {
|
||||
if (!this.state.isExpanded) {
|
||||
// don't waste time on rendering
|
||||
return [];
|
||||
}
|
||||
|
||||
const tiles: React.ReactElement[] = [];
|
||||
|
||||
if (this.props.rooms) {
|
||||
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
|
||||
for (const room of visibleRooms) {
|
||||
tiles.push(
|
||||
<RoomTile
|
||||
room={room}
|
||||
key={`room-${room.roomId}`}
|
||||
showMessagePreview={this.layout.showPreviews}
|
||||
isMinimized={this.props.isMinimized}
|
||||
tag={this.props.tagId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.extraBadTilesThatShouldntExist) {
|
||||
tiles.push(...this.props.extraBadTilesThatShouldntExist);
|
||||
}
|
||||
|
||||
// We only have to do this because of the extra tiles. We do it conditionally
|
||||
// to avoid spending cycles on slicing. It's generally fine to do this though
|
||||
// as users are unlikely to have more than a handful of tiles when the extra
|
||||
// tiles are used.
|
||||
if (tiles.length > this.numVisibleTiles) {
|
||||
return tiles.slice(0, this.numVisibleTiles);
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private renderMenu(): React.ReactElement {
|
||||
let contextMenu = null;
|
||||
if (this.state.contextMenuPosition) {
|
||||
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
|
||||
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
|
||||
|
||||
// Invites don't get some nonsense options, so only add them if we have to.
|
||||
let otherSections = null;
|
||||
if (this.props.tagId !== DefaultTagID.Invite) {
|
||||
otherSections = (
|
||||
<React.Fragment>
|
||||
<hr />
|
||||
<div>
|
||||
<div className='mx_RoomSublist_contextMenu_title'>{_t("Appearance")}</div>
|
||||
<StyledMenuItemCheckbox
|
||||
onClose={this.onCloseMenu}
|
||||
onChange={this.onUnreadFirstChanged}
|
||||
checked={isUnreadFirst}
|
||||
>
|
||||
{_t("Show rooms with unread messages first")}
|
||||
</StyledMenuItemCheckbox>
|
||||
<StyledMenuItemCheckbox
|
||||
onClose={this.onCloseMenu}
|
||||
onChange={this.onMessagePreviewChanged}
|
||||
checked={this.layout.showPreviews}
|
||||
>
|
||||
{_t("Show previews of messages")}
|
||||
</StyledMenuItemCheckbox>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
left={this.state.contextMenuPosition.left}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_RoomSublist_contextMenu">
|
||||
<div>
|
||||
<div className='mx_RoomSublist_contextMenu_title'>{_t("Sort by")}</div>
|
||||
<StyledMenuItemRadio
|
||||
onClose={this.onCloseMenu}
|
||||
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
|
||||
checked={!isAlphabetical}
|
||||
name={`mx_${this.props.tagId}_sortBy`}
|
||||
>
|
||||
{_t("Activity")}
|
||||
</StyledMenuItemRadio>
|
||||
<StyledMenuItemRadio
|
||||
onClose={this.onCloseMenu}
|
||||
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
|
||||
checked={isAlphabetical}
|
||||
name={`mx_${this.props.tagId}_sortBy`}
|
||||
>
|
||||
{_t("A-Z")}
|
||||
</StyledMenuItemRadio>
|
||||
</div>
|
||||
{otherSections}
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_RoomSublist_menuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
title={_t("List options")}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderHeader(): React.ReactElement {
|
||||
return (
|
||||
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
let ariaLabel = _t("Jump to first unread room.");
|
||||
if (this.props.tagId === DefaultTagID.Invite) {
|
||||
ariaLabel = _t("Jump to first invite.");
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<NotificationBadge
|
||||
forceCount={true}
|
||||
notification={this.state.notificationState}
|
||||
onClick={this.onBadgeClick}
|
||||
tabIndex={tabIndex}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
let addRoomButton = null;
|
||||
if (!!this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSublist_auxButton"
|
||||
aria-label={this.props.addRoomLabel || _t("Add room")}
|
||||
title={this.props.addRoomLabel}
|
||||
tooltipClassName={"mx_RoomSublist_addRoomTooltip"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const collapseClasses = classNames({
|
||||
'mx_RoomSublist_collapseBtn': true,
|
||||
'mx_RoomSublist_collapseBtn_collapsed': !this.state.isExpanded,
|
||||
});
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomSublist_headerContainer': true,
|
||||
'mx_RoomSublist_headerContainer_withAux': !!addRoomButton,
|
||||
});
|
||||
|
||||
const badgeContainer = (
|
||||
<div className="mx_RoomSublist_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
);
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
if (this.props.isMinimized) {
|
||||
Button = AccessibleTooltipButton;
|
||||
}
|
||||
|
||||
// Note: the addRoomButton conditionally gets moved around
|
||||
// the DOM depending on whether or not the list is minimized.
|
||||
// If we're minimized, we want it below the header so it
|
||||
// doesn't become sticky.
|
||||
// The same applies to the notification badge.
|
||||
return (
|
||||
<div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus} aria-label={this.props.label}>
|
||||
<div className="mx_RoomSublist_stickable">
|
||||
<Button
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className="mx_RoomSublist_headerText"
|
||||
role="treeitem"
|
||||
aria-expanded={this.state.isExpanded}
|
||||
aria-level={1}
|
||||
onClick={this.onHeaderClick}
|
||||
onContextMenu={this.onContextMenu}
|
||||
title={this.props.isMinimized ? this.props.label : undefined}
|
||||
>
|
||||
<span className={collapseClasses} />
|
||||
<span>{this.props.label}</span>
|
||||
</Button>
|
||||
{this.renderMenu()}
|
||||
{this.props.isMinimized ? null : badgeContainer}
|
||||
{this.props.isMinimized ? null : addRoomButton}
|
||||
</div>
|
||||
{this.props.isMinimized ? badgeContainer : null}
|
||||
{this.props.isMinimized ? addRoomButton : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</RovingTabIndexWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
private onScrollPrevent(e: React.UIEvent<HTMLDivElement>) {
|
||||
// the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
|
||||
// this fixes https://github.com/vector-im/riot-web/issues/14413
|
||||
(e.target as HTMLDivElement).scrollTop = 0;
|
||||
}
|
||||
|
||||
public render(): React.ReactElement {
|
||||
const visibleTiles = this.renderVisibleTiles();
|
||||
const classes = classNames({
|
||||
'mx_RoomSublist': true,
|
||||
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
|
||||
'mx_RoomSublist_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
let content = null;
|
||||
if (visibleTiles.length > 0) {
|
||||
const layout = this.layout; // to shorten calls
|
||||
|
||||
const minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
|
||||
const showMoreAtMinHeight = minTiles < this.numTiles;
|
||||
const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
|
||||
const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
|
||||
let maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
|
||||
const showMoreBtnClasses = classNames({
|
||||
'mx_RoomSublist_showNButton': true,
|
||||
});
|
||||
|
||||
// If we're hiding rooms, show a 'show more' button to the user. This button
|
||||
// floats above the resize handle, if we have one present. If the user has all
|
||||
// tiles visible, it becomes 'show less'.
|
||||
let showNButton = null;
|
||||
|
||||
if (maxTilesPx > this.state.height) {
|
||||
// the height of all the tiles is greater than the section height: we need a 'show more' button
|
||||
const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
|
||||
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
|
||||
const numMissing = this.numTiles - amountFullyShown;
|
||||
let showMoreText = (
|
||||
<span className='mx_RoomSublist_showNButtonText'>
|
||||
{_t("Show %(count)s more", {count: numMissing})}
|
||||
</span>
|
||||
);
|
||||
if (this.props.isMinimized) showMoreText = null;
|
||||
showNButton = (
|
||||
<RovingAccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses}>
|
||||
<span className='mx_RoomSublist_showMoreButtonChevron mx_RoomSublist_showNButtonChevron'>
|
||||
{/* set by CSS masking */}
|
||||
</span>
|
||||
{showMoreText}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
|
||||
// we have all tiles visible - add a button to show less
|
||||
let showLessText = (
|
||||
<span className='mx_RoomSublist_showNButtonText'>
|
||||
{_t("Show less")}
|
||||
</span>
|
||||
);
|
||||
if (this.props.isMinimized) showLessText = null;
|
||||
showNButton = (
|
||||
<RovingAccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses}>
|
||||
<span className='mx_RoomSublist_showLessButtonChevron mx_RoomSublist_showNButtonChevron'>
|
||||
{/* set by CSS masking */}
|
||||
</span>
|
||||
{showLessText}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Figure out if we need a handle
|
||||
const handles: Enable = {
|
||||
bottom: true, // the only one we need, but the others must be explicitly false
|
||||
bottomLeft: false,
|
||||
bottomRight: false,
|
||||
left: false,
|
||||
right: false,
|
||||
top: false,
|
||||
topLeft: false,
|
||||
topRight: false,
|
||||
};
|
||||
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
|
||||
// we're at a minimum, don't have a bottom handle
|
||||
handles.bottom = false;
|
||||
}
|
||||
|
||||
// We have to account for padding so we can accommodate a 'show more' button and
|
||||
// the resize handle, which are pinned to the bottom of the container. This is the
|
||||
// easiest way to have a resize handle below the button as otherwise we're writing
|
||||
// our own resize handling and that doesn't sound fun.
|
||||
//
|
||||
// The layout class has some helpers for dealing with padding, as we don't want to
|
||||
// apply it in all cases. If we apply it in all cases, the resizing feels like it
|
||||
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
|
||||
// only mathematically 7 possible).
|
||||
|
||||
const handleWrapperClasses = classNames({
|
||||
'mx_RoomSublist_resizerHandles': true,
|
||||
'mx_RoomSublist_resizerHandles_showNButton': !!showNButton,
|
||||
});
|
||||
|
||||
content = (
|
||||
<React.Fragment>
|
||||
<Resizable
|
||||
size={{height: this.state.height} as any}
|
||||
minHeight={minTilesPx}
|
||||
maxHeight={maxTilesPx}
|
||||
onResizeStart={this.onResizeStart}
|
||||
onResizeStop={this.onResizeStop}
|
||||
onResize={this.onResize}
|
||||
handleWrapperClass={handleWrapperClasses}
|
||||
handleClasses={{bottom: "mx_RoomSublist_resizerHandle"}}
|
||||
className="mx_RoomSublist_resizeBox"
|
||||
enable={handles}
|
||||
>
|
||||
<div className="mx_RoomSublist_tiles" onScroll={this.onScrollPrevent}>
|
||||
{visibleTiles}
|
||||
</div>
|
||||
{showNButton}
|
||||
</Resizable>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.sublistRef}
|
||||
className={classes}
|
||||
role="group"
|
||||
aria-label={this.props.label}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{this.renderHeader()}
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,472 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 Vector Creations Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from 'classnames';
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import RoomTile2 from "./RoomTile2";
|
||||
import { ResizableBox, ResizeCallbackData } from "react-resizable";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
|
||||
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import StyledRadioButton from "../elements/StyledRadioButton";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore2";
|
||||
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
|
||||
import { TagID } from "../../../stores/room-list/models";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
|
||||
const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
|
||||
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
|
||||
|
||||
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
|
||||
|
||||
interface IProps {
|
||||
forRooms: boolean;
|
||||
rooms?: Room[];
|
||||
startAsHidden: boolean;
|
||||
label: string;
|
||||
onAddRoom?: () => void;
|
||||
addRoomLabel: string;
|
||||
isInvite: boolean;
|
||||
layout: ListLayout;
|
||||
isMinimized: boolean;
|
||||
tagId: TagID;
|
||||
|
||||
// TODO: Collapsed state
|
||||
// TODO: Group invites
|
||||
// TODO: Calls
|
||||
// TODO: forceExpand?
|
||||
// TODO: Header clicking
|
||||
// TODO: Spinner support for historical
|
||||
}
|
||||
|
||||
interface IState {
|
||||
notificationState: ListNotificationState;
|
||||
menuDisplayed: boolean;
|
||||
isResizing: boolean;
|
||||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef();
|
||||
private menuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
|
||||
menuDisplayed: false,
|
||||
isResizing: false,
|
||||
};
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
// TODO: Account for group invites
|
||||
return (this.props.rooms || []).length;
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.state.notificationState.destroy();
|
||||
}
|
||||
|
||||
private onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
};
|
||||
|
||||
private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => {
|
||||
const direction = e.movementY < 0 ? -1 : +1;
|
||||
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction;
|
||||
this.props.layout.visibleTiles += tileDiff;
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private onResizeStart = () => {
|
||||
this.setState({isResizing: true});
|
||||
};
|
||||
|
||||
private onResizeStop = () => {
|
||||
this.setState({isResizing: false});
|
||||
};
|
||||
|
||||
private onShowAllClick = () => {
|
||||
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private onShowLessClick = () => {
|
||||
this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles;
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: true});
|
||||
};
|
||||
|
||||
private onCloseMenu = () => {
|
||||
this.setState({menuDisplayed: false});
|
||||
};
|
||||
|
||||
private onUnreadFirstChanged = async () => {
|
||||
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
|
||||
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
|
||||
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
|
||||
};
|
||||
|
||||
private onTagSortChanged = async (sort: SortAlgorithm) => {
|
||||
await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
|
||||
};
|
||||
|
||||
private onMessagePreviewChanged = () => {
|
||||
this.props.layout.showPreviews = !this.props.layout.showPreviews;
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private onHeaderClick = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
let target = ev.target as HTMLDivElement;
|
||||
if (!target.classList.contains('mx_RoomSublist2_headerText')) {
|
||||
// If we don't have the headerText class, the user clicked the span in the headerText.
|
||||
target = target.parentElement as HTMLDivElement;
|
||||
}
|
||||
|
||||
const possibleSticky = target.parentElement;
|
||||
const sublist = possibleSticky.parentElement.parentElement;
|
||||
if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) {
|
||||
// is sticky - jump to list
|
||||
sublist.scrollIntoView({behavior: 'smooth'});
|
||||
} else {
|
||||
// on screen - toggle collapse
|
||||
this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
|
||||
this.forceUpdate(); // because the layout doesn't trigger an update
|
||||
}
|
||||
};
|
||||
|
||||
private renderTiles(): React.ReactElement[] {
|
||||
if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering
|
||||
|
||||
const tiles: React.ReactElement[] = [];
|
||||
|
||||
if (this.props.rooms) {
|
||||
for (const room of this.props.rooms) {
|
||||
tiles.push(
|
||||
<RoomTile2
|
||||
room={room}
|
||||
key={`room-${room.roomId}`}
|
||||
showMessagePreview={this.props.layout.showPreviews}
|
||||
isMinimized={this.props.isMinimized}
|
||||
tag={this.props.tagId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private renderMenu(): React.ReactElement {
|
||||
let contextMenu = null;
|
||||
if (this.state.menuDisplayed) {
|
||||
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
|
||||
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
|
||||
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_RoomSublist2_contextMenu">
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div>
|
||||
<StyledRadioButton
|
||||
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
|
||||
checked={!isAlphabetical}
|
||||
name={`mx_${this.props.tagId}_sortBy`}
|
||||
>
|
||||
{_t("Activity")}
|
||||
</StyledRadioButton>
|
||||
<StyledRadioButton
|
||||
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
|
||||
checked={isAlphabetical}
|
||||
name={`mx_${this.props.tagId}_sortBy`}
|
||||
>
|
||||
{_t("A-Z")}
|
||||
</StyledRadioButton>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
|
||||
<StyledCheckbox
|
||||
onChange={this.onUnreadFirstChanged}
|
||||
checked={isUnreadFirst}
|
||||
>
|
||||
{_t("Always show first")}
|
||||
</StyledCheckbox>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
|
||||
<StyledCheckbox
|
||||
onChange={this.onMessagePreviewChanged}
|
||||
checked={this.props.layout.showPreviews}
|
||||
>
|
||||
{_t("Message preview")}
|
||||
</StyledCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_RoomSublist2_menuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.menuButtonRef}
|
||||
label={_t("List options")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderHeader(): React.ReactElement {
|
||||
// TODO: Title on collapsed
|
||||
// TODO: Incoming call box
|
||||
|
||||
return (
|
||||
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
// TODO: Use onFocus
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
// TODO: Collapsed state
|
||||
|
||||
const badge = <NotificationBadge forceCount={true} notification={this.state.notificationState}/>;
|
||||
|
||||
let addRoomButton = null;
|
||||
if (!!this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSublist2_auxButton"
|
||||
aria-label={this.props.addRoomLabel || _t("Add room")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const collapseClasses = classNames({
|
||||
'mx_RoomSublist2_collapseBtn': true,
|
||||
'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed,
|
||||
});
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomSublist2_headerContainer': true,
|
||||
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
|
||||
});
|
||||
|
||||
const badgeContainer = (
|
||||
<div className="mx_RoomSublist2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
);
|
||||
|
||||
// TODO: a11y (see old component)
|
||||
// Note: the addRoomButton conditionally gets moved around
|
||||
// the DOM depending on whether or not the list is minimized.
|
||||
// If we're minimized, we want it below the header so it
|
||||
// doesn't become sticky.
|
||||
// The same applies to the notification badge.
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className='mx_RoomSublist2_stickable'>
|
||||
<AccessibleButton
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className={"mx_RoomSublist2_headerText"}
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
onClick={this.onHeaderClick}
|
||||
>
|
||||
<span className={collapseClasses} />
|
||||
<span>{this.props.label}</span>
|
||||
</AccessibleButton>
|
||||
{this.renderMenu()}
|
||||
{this.props.isMinimized ? null : badgeContainer}
|
||||
{this.props.isMinimized ? null : addRoomButton}
|
||||
</div>
|
||||
{this.props.isMinimized ? badgeContainer : null}
|
||||
{this.props.isMinimized ? addRoomButton : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</RovingTabIndexWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// TODO: Proper rendering
|
||||
// TODO: Error boundary
|
||||
|
||||
const tiles = this.renderTiles();
|
||||
|
||||
const classes = classNames({
|
||||
// TODO: Proper collapse support
|
||||
'mx_RoomSublist2': true,
|
||||
'mx_RoomSublist2_collapsed': false, // len && isCollapsed
|
||||
'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed,
|
||||
'mx_RoomSublist2_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
let content = null;
|
||||
if (tiles.length > 0) {
|
||||
const layout = this.props.layout; // to shorten calls
|
||||
|
||||
// TODO: Lazy list rendering
|
||||
// TODO: Whatever scrolling magic needs to happen here
|
||||
|
||||
const nVisible = Math.floor(layout.visibleTiles);
|
||||
const visibleTiles = tiles.slice(0, nVisible);
|
||||
|
||||
const maxTilesFactored = layout.tilesWithResizerBoxFactor(tiles.length);
|
||||
const showMoreBtnClasses = classNames({
|
||||
'mx_RoomSublist2_showNButton': true,
|
||||
'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored,
|
||||
});
|
||||
|
||||
// If we're hiding rooms, show a 'show more' button to the user. This button
|
||||
// floats above the resize handle, if we have one present. If the user has all
|
||||
// tiles visible, it becomes 'show less'.
|
||||
let showNButton = null;
|
||||
if (tiles.length > nVisible) {
|
||||
// we have a cutoff condition - add the button to show all
|
||||
const numMissing = tiles.length - visibleTiles.length;
|
||||
let showMoreText = (
|
||||
<span className='mx_RoomSublist2_showNButtonText'>
|
||||
{_t("Show %(count)s more", {count: numMissing})}
|
||||
</span>
|
||||
);
|
||||
if (this.props.isMinimized) showMoreText = null;
|
||||
showNButton = (
|
||||
<div onClick={this.onShowAllClick} className={showMoreBtnClasses}>
|
||||
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
|
||||
{/* set by CSS masking */}
|
||||
</span>
|
||||
{showMoreText}
|
||||
</div>
|
||||
);
|
||||
} else if (tiles.length <= nVisible && tiles.length > this.props.layout.defaultVisibleTiles) {
|
||||
// we have all tiles visible - add a button to show less
|
||||
let showLessText = (
|
||||
<span className='mx_RoomSublist2_showNButtonText'>
|
||||
{_t("Show less")}
|
||||
</span>
|
||||
);
|
||||
if (this.props.isMinimized) showLessText = null;
|
||||
showNButton = (
|
||||
<div onClick={this.onShowLessClick} className={showMoreBtnClasses}>
|
||||
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
|
||||
{/* set by CSS masking */}
|
||||
</span>
|
||||
{showLessText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Figure out if we need a handle
|
||||
let handles = ['s'];
|
||||
if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) {
|
||||
handles = []; // no handles, we're at a minimum
|
||||
}
|
||||
|
||||
// We have to account for padding so we can accommodate a 'show more' button and
|
||||
// the resize handle, which are pinned to the bottom of the container. This is the
|
||||
// easiest way to have a resize handle below the button as otherwise we're writing
|
||||
// our own resize handling and that doesn't sound fun.
|
||||
//
|
||||
// The layout class has some helpers for dealing with padding, as we don't want to
|
||||
// apply it in all cases. If we apply it in all cases, the resizing feels like it
|
||||
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
|
||||
// only mathematically 7 possible).
|
||||
|
||||
// The padding is variable though, so figure out what we need padding for.
|
||||
let padding = 0;
|
||||
if (showNButton) padding += SHOW_N_BUTTON_HEIGHT;
|
||||
padding += RESIZE_HANDLE_HEIGHT; // always append the handle height
|
||||
|
||||
const relativeTiles = layout.tilesWithPadding(tiles.length, padding);
|
||||
const minTilesPx = layout.calculateTilesToPixelsMin(relativeTiles, layout.minVisibleTiles, padding);
|
||||
const maxTilesPx = layout.tilesToPixelsWithPadding(tiles.length, padding);
|
||||
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
|
||||
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
|
||||
|
||||
content = (
|
||||
<ResizableBox
|
||||
width={-1}
|
||||
height={tilesPx}
|
||||
axis="y"
|
||||
minConstraints={[-1, minTilesPx]}
|
||||
maxConstraints={[-1, maxTilesPx]}
|
||||
resizeHandles={handles}
|
||||
onResize={this.onResize}
|
||||
className="mx_RoomSublist2_resizeBox"
|
||||
onResizeStart={this.onResizeStart}
|
||||
onResizeStop={this.onResizeStop}
|
||||
>
|
||||
{visibleTiles}
|
||||
{showNButton}
|
||||
</ResizableBox>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: onKeyDown support
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
role="group"
|
||||
aria-label={this.props.label}
|
||||
>
|
||||
{this.renderHeader()}
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,565 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 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, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import classNames from 'classnames';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
import * as sdk from '../../../index';
|
||||
import {ContextMenu, ContextMenuButton, toRightOf} from '../../structures/ContextMenu';
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import * as FormattingUtils from '../../../utils/FormattingUtils';
|
||||
import ActiveRoomObserver from '../../../ActiveRoomObserver';
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
|
||||
import E2EIcon from './E2EIcon';
|
||||
import InviteOnlyIcon from './InviteOnlyIcon';
|
||||
// eslint-disable-next-line camelcase
|
||||
import rate_limited_func from '../../../ratelimitedfunc';
|
||||
import { shieldStatusForRoom } from '../../../utils/ShieldUtils';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomTile',
|
||||
|
||||
propTypes: {
|
||||
onClick: PropTypes.func,
|
||||
|
||||
room: PropTypes.object.isRequired,
|
||||
collapsed: PropTypes.bool.isRequired,
|
||||
unread: PropTypes.bool.isRequired,
|
||||
highlight: PropTypes.bool.isRequired,
|
||||
// If true, apply mx_RoomTile_transparent class
|
||||
transparent: PropTypes.bool,
|
||||
isInvite: PropTypes.bool.isRequired,
|
||||
incomingCall: PropTypes.object,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
isDragging: false,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
const joinRules = this.props.room.currentState.getStateEvents("m.room.join_rules", "");
|
||||
const joinRule = joinRules && joinRules.getContent().join_rule;
|
||||
|
||||
return ({
|
||||
joinRule,
|
||||
hover: false,
|
||||
badgeHover: false,
|
||||
contextMenuPosition: null, // DOM bounding box, null if non-shown
|
||||
roomName: this.props.room.name,
|
||||
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
|
||||
notificationCount: this.props.room.getUnreadNotificationCount(),
|
||||
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
|
||||
statusMessage: this._getStatusMessage(),
|
||||
e2eStatus: null,
|
||||
});
|
||||
},
|
||||
|
||||
_shouldShowStatusMessage() {
|
||||
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
|
||||
return false;
|
||||
}
|
||||
const isInvite = this.props.room.getMyMembership() === "invite";
|
||||
const isJoined = this.props.room.getMyMembership() === "join";
|
||||
const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2;
|
||||
return !isInvite && isJoined && looksLikeDm;
|
||||
},
|
||||
|
||||
_getStatusMessageUser() {
|
||||
if (!MatrixClientPeg.get()) return null; // We've probably been logged out
|
||||
|
||||
const selfId = MatrixClientPeg.get().getUserId();
|
||||
const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0];
|
||||
if (!otherMember) {
|
||||
return null;
|
||||
}
|
||||
return otherMember.user;
|
||||
},
|
||||
|
||||
_getStatusMessage() {
|
||||
const statusUser = this._getStatusMessageUser();
|
||||
if (!statusUser) {
|
||||
return "";
|
||||
}
|
||||
return statusUser._unstable_statusMessage;
|
||||
},
|
||||
|
||||
onRoomStateMember: function(ev, state, member) {
|
||||
// we only care about leaving users
|
||||
// because trust state will change if someone joins a megolm session anyway
|
||||
if (member.membership !== "leave") {
|
||||
return;
|
||||
}
|
||||
// ignore members in other rooms
|
||||
if (member.roomId !== this.props.room.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateE2eStatus();
|
||||
},
|
||||
|
||||
onUserVerificationChanged: function(userId, _trustStatus) {
|
||||
if (!this.props.room.getMember(userId)) {
|
||||
// Not in this room
|
||||
return;
|
||||
}
|
||||
this._updateE2eStatus();
|
||||
},
|
||||
|
||||
onCrossSigningKeysChanged: function() {
|
||||
this._updateE2eStatus();
|
||||
},
|
||||
|
||||
onRoomTimeline: function(ev, room) {
|
||||
if (!room) return;
|
||||
if (room.roomId != this.props.room.roomId) return;
|
||||
if (ev.getType() !== "m.room.encryption") return;
|
||||
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
|
||||
this.onFindingRoomToBeEncrypted();
|
||||
},
|
||||
|
||||
onFindingRoomToBeEncrypted: function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
cli.on("RoomState.members", this.onRoomStateMember);
|
||||
cli.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
cli.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this._updateE2eStatus();
|
||||
},
|
||||
|
||||
_updateE2eStatus: async function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli.isRoomEncrypted(this.props.room.roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
this.setState({
|
||||
e2eStatus: await shieldStatusForRoom(cli, this.props.room),
|
||||
});
|
||||
},
|
||||
|
||||
onRoomName: function(room) {
|
||||
if (room !== this.props.room) return;
|
||||
this.setState({
|
||||
roomName: this.props.room.name,
|
||||
});
|
||||
},
|
||||
|
||||
onJoinRule: function(ev) {
|
||||
if (ev.getType() !== "m.room.join_rules") return;
|
||||
if (ev.getRoomId() !== this.props.room.roomId) return;
|
||||
this.setState({ joinRule: ev.getContent().join_rule });
|
||||
},
|
||||
|
||||
onAccountData: function(accountDataEvent) {
|
||||
if (accountDataEvent.getType() === 'm.push_rules') {
|
||||
this.setState({
|
||||
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
switch (payload.action) {
|
||||
// XXX: slight hack in order to zero the notification count when a room
|
||||
// is read. Ideally this state would be given to this via props (as we
|
||||
// do with `unread`). This is still better than forceUpdating the entire
|
||||
// RoomList when a room is read.
|
||||
case 'on_room_read':
|
||||
if (payload.roomId !== this.props.room.roomId) break;
|
||||
this.setState({
|
||||
notificationCount: this.props.room.getUnreadNotificationCount(),
|
||||
});
|
||||
break;
|
||||
// RoomTiles are one of the few components that may show custom status and
|
||||
// also remain on screen while in Settings toggling the feature. This ensures
|
||||
// you can clearly see the status hide and show when toggling the feature.
|
||||
case 'feature_custom_status_changed':
|
||||
this.forceUpdate();
|
||||
break;
|
||||
|
||||
case 'view_room':
|
||||
// when the room is selected make sure its tile is visible, for breadcrumbs/keyboard shortcut access
|
||||
if (payload.room_id === this.props.room.roomId && payload.show_room_tile) {
|
||||
this._scrollIntoView();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_scrollIntoView: function() {
|
||||
if (!this._roomTile.current) return;
|
||||
this._roomTile.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "auto",
|
||||
});
|
||||
},
|
||||
|
||||
_onActiveRoomChange: function() {
|
||||
this.setState({
|
||||
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
|
||||
});
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._roomTile = createRef();
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
/* We bind here rather than in the definition because otherwise we wind up with the
|
||||
method only being callable once every 500ms across all instances, which would be wrong */
|
||||
this._updateE2eStatus = rate_limited_func(this._updateE2eStatus, 500);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
cli.on("accountData", this.onAccountData);
|
||||
cli.on("Room.name", this.onRoomName);
|
||||
cli.on("RoomState.events", this.onJoinRule);
|
||||
if (cli.isRoomEncrypted(this.props.room.roomId)) {
|
||||
this.onFindingRoomToBeEncrypted();
|
||||
} else {
|
||||
cli.on("Room.timeline", this.onRoomTimeline);
|
||||
}
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
if (this._shouldShowStatusMessage()) {
|
||||
const statusUser = this._getStatusMessageUser();
|
||||
if (statusUser) {
|
||||
statusUser.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
|
||||
}
|
||||
}
|
||||
|
||||
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
|
||||
if (this.state.selected) {
|
||||
this._scrollIntoView();
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
|
||||
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
|
||||
cli.removeListener("RoomState.events", this.onJoinRule);
|
||||
cli.removeListener("RoomState.members", this.onRoomStateMember);
|
||||
cli.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
cli.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
cli.removeListener("Room.timeline", this.onRoomTimeline);
|
||||
}
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
|
||||
if (this._shouldShowStatusMessage()) {
|
||||
const statusUser = this._getStatusMessageUser();
|
||||
if (statusUser) {
|
||||
statusUser.removeListener(
|
||||
"User._unstable_statusMessage",
|
||||
this._onStatusMessageCommitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(props) {
|
||||
// XXX: This could be a lot better - this makes the assumption that
|
||||
// the notification count may have changed when the properties of
|
||||
// the room tile change.
|
||||
this.setState({
|
||||
notificationCount: this.props.room.getUnreadNotificationCount(),
|
||||
});
|
||||
},
|
||||
|
||||
// Do a simple shallow comparison of props and state to avoid unnecessary
|
||||
// renders. The assumption made here is that only state and props are used
|
||||
// in rendering this component and children.
|
||||
//
|
||||
// RoomList is frequently made to forceUpdate, so this decreases number of
|
||||
// RoomTile renderings.
|
||||
shouldComponentUpdate: function(newProps, newState) {
|
||||
if (Object.keys(newProps).some((k) => newProps[k] !== this.props[k])) {
|
||||
return true;
|
||||
}
|
||||
if (Object.keys(newState).some((k) => newState[k] !== this.state[k])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
_onStatusMessageCommitted() {
|
||||
// The status message `User` object has observed a message change.
|
||||
this.setState({
|
||||
statusMessage: this._getStatusMessage(),
|
||||
});
|
||||
},
|
||||
|
||||
onClick: function(ev) {
|
||||
if (this.props.onClick) {
|
||||
this.props.onClick(this.props.room.roomId, ev);
|
||||
}
|
||||
},
|
||||
|
||||
onMouseEnter: function() {
|
||||
this.setState( { hover: true });
|
||||
this.badgeOnMouseEnter();
|
||||
},
|
||||
|
||||
onMouseLeave: function() {
|
||||
this.setState( { hover: false });
|
||||
this.badgeOnMouseLeave();
|
||||
},
|
||||
|
||||
badgeOnMouseEnter: function() {
|
||||
// Only allow non-guests to access the context menu
|
||||
// and only change it if it needs to change
|
||||
if (!MatrixClientPeg.get().isGuest() && !this.state.badgeHover) {
|
||||
this.setState( { badgeHover: true } );
|
||||
}
|
||||
},
|
||||
|
||||
badgeOnMouseLeave: function() {
|
||||
this.setState( { badgeHover: false } );
|
||||
},
|
||||
|
||||
_showContextMenu: function(boundingClientRect) {
|
||||
// Only allow non-guests to access the context menu
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
const state = {
|
||||
contextMenuPosition: boundingClientRect,
|
||||
};
|
||||
|
||||
// If the badge is clicked, then no longer show tooltip
|
||||
if (this.props.collapsed) {
|
||||
state.hover = false;
|
||||
}
|
||||
|
||||
this.setState(state);
|
||||
},
|
||||
|
||||
onContextMenuButtonClick: function(e) {
|
||||
// Prevent the RoomTile onClick event firing as well
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
this._showContextMenu(e.target.getBoundingClientRect());
|
||||
},
|
||||
|
||||
onContextMenu: function(e) {
|
||||
// Prevent the native context menu
|
||||
e.preventDefault();
|
||||
|
||||
this._showContextMenu({
|
||||
right: e.clientX,
|
||||
top: e.clientY,
|
||||
height: 0,
|
||||
});
|
||||
},
|
||||
|
||||
closeMenu: function() {
|
||||
this.setState({
|
||||
contextMenuPosition: null,
|
||||
});
|
||||
this.props.refreshSubList();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const isInvite = this.props.room.getMyMembership() === "invite";
|
||||
const notificationCount = this.props.notificationCount;
|
||||
// var highlightCount = this.props.room.getUnreadNotificationCount("highlight");
|
||||
|
||||
const notifBadges = notificationCount > 0 && RoomNotifs.shouldShowNotifBadge(this.state.notifState);
|
||||
const mentionBadges = this.props.highlight && RoomNotifs.shouldShowMentionBadge(this.state.notifState);
|
||||
const badges = notifBadges || mentionBadges;
|
||||
|
||||
let subtext = null;
|
||||
if (this._shouldShowStatusMessage()) {
|
||||
subtext = this.state.statusMessage;
|
||||
}
|
||||
|
||||
const isMenuDisplayed = Boolean(this.state.contextMenuPosition);
|
||||
|
||||
const dmUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomTile': true,
|
||||
'mx_RoomTile_selected': this.state.selected,
|
||||
'mx_RoomTile_unread': this.props.unread,
|
||||
'mx_RoomTile_unreadNotify': notifBadges,
|
||||
'mx_RoomTile_highlight': mentionBadges,
|
||||
'mx_RoomTile_invited': isInvite,
|
||||
'mx_RoomTile_menuDisplayed': isMenuDisplayed,
|
||||
'mx_RoomTile_noBadges': !badges,
|
||||
'mx_RoomTile_transparent': this.props.transparent,
|
||||
'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
|
||||
});
|
||||
|
||||
const avatarClasses = classNames({
|
||||
'mx_RoomTile_avatar': true,
|
||||
});
|
||||
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomTile_badge': true,
|
||||
'mx_RoomTile_badgeButton': this.state.badgeHover || isMenuDisplayed,
|
||||
});
|
||||
|
||||
let name = this.state.roomName;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
let badge;
|
||||
if (badges) {
|
||||
const limitedCount = FormattingUtils.formatCount(notificationCount);
|
||||
const badgeContent = notificationCount ? limitedCount : '!';
|
||||
badge = <div className={badgeClasses}>{ badgeContent }</div>;
|
||||
}
|
||||
|
||||
let label;
|
||||
let subtextLabel;
|
||||
let tooltip;
|
||||
if (!this.props.collapsed) {
|
||||
const nameClasses = classNames({
|
||||
'mx_RoomTile_name': true,
|
||||
'mx_RoomTile_invite': this.props.isInvite,
|
||||
'mx_RoomTile_badgeShown': badges || this.state.badgeHover || isMenuDisplayed,
|
||||
});
|
||||
|
||||
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
|
||||
// XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
|
||||
label = <div title={name} className={nameClasses} tabIndex={-1} dir="auto">{ name }</div>;
|
||||
} else if (this.state.hover) {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
|
||||
}
|
||||
|
||||
//var incomingCallBox;
|
||||
//if (this.props.incomingCall) {
|
||||
// var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
|
||||
// incomingCallBox = <IncomingCallBox incomingCall={ this.props.incomingCall }/>;
|
||||
//}
|
||||
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let contextMenuButton;
|
||||
if (!MatrixClientPeg.get().isGuest()) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuButton
|
||||
className="mx_RoomTile_menuButton"
|
||||
label={_t("Options")}
|
||||
isExpanded={isMenuDisplayed}
|
||||
onClick={this.onContextMenuButtonClick} />
|
||||
);
|
||||
}
|
||||
|
||||
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
||||
|
||||
let ariaLabel = name;
|
||||
|
||||
let dmOnline;
|
||||
const { room } = this.props;
|
||||
const member = room.getMember(dmUserId);
|
||||
if (member && member.membership === "join" && room.getJoinedMemberCount() === 2) {
|
||||
const UserOnlineDot = sdk.getComponent('rooms.UserOnlineDot');
|
||||
dmOnline = <UserOnlineDot userId={dmUserId} />;
|
||||
}
|
||||
|
||||
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
|
||||
if (notifBadges && mentionBadges && !isInvite) {
|
||||
ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
|
||||
count: notificationCount,
|
||||
});
|
||||
} else if (notifBadges) {
|
||||
ariaLabel += " " + _t("%(count)s unread messages.", { count: notificationCount });
|
||||
} else if (mentionBadges && !isInvite) {
|
||||
ariaLabel += " " + _t("Unread mentions.");
|
||||
} else if (this.props.unread) {
|
||||
ariaLabel += " " + _t("Unread messages.");
|
||||
}
|
||||
|
||||
let contextMenu;
|
||||
if (isMenuDisplayed) {
|
||||
const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
|
||||
contextMenu = (
|
||||
<ContextMenu {...toRightOf(this.state.contextMenuPosition)} onFinished={this.closeMenu}>
|
||||
<RoomTileContextMenu room={this.props.room} onFinished={this.closeMenu} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
let privateIcon = null;
|
||||
if (this.state.joinRule === "invite" && !dmUserId) {
|
||||
privateIcon = <InviteOnlyIcon collapsedPanel={this.props.collapsed} />;
|
||||
}
|
||||
|
||||
let e2eIcon = null;
|
||||
if (this.state.e2eStatus) {
|
||||
e2eIcon = <E2EIcon status={this.state.e2eStatus} className="mx_RoomTile_e2eIcon" />;
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this._roomTile}>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
inputRef={ref}
|
||||
className={classes}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onContextMenu={this.onContextMenu}
|
||||
aria-label={ariaLabel}
|
||||
aria-selected={this.state.selected}
|
||||
role="treeitem"
|
||||
>
|
||||
<div className={avatarClasses}>
|
||||
<div className="mx_RoomTile_avatar_container">
|
||||
<RoomAvatar room={this.props.room} width={24} height={24} />
|
||||
{ e2eIcon }
|
||||
</div>
|
||||
</div>
|
||||
{ privateIcon }
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div className="mx_RoomTile_labelContainer">
|
||||
{ label }
|
||||
{ subtextLabel }
|
||||
</div>
|
||||
{ dmOnline }
|
||||
{ contextMenuButton }
|
||||
{ badge }
|
||||
</div>
|
||||
{ /* { incomingCallBox } */ }
|
||||
{ tooltip }
|
||||
</AccessibleButton>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
},
|
||||
});
|
574
src/components/views/rooms/RoomTile.tsx
Normal file
574
src/components/views/rooms/RoomTile.tsx
Normal file
|
@ -0,0 +1,574 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019, 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 React, {createRef} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
ContextMenuTooltipButton,
|
||||
MenuItemRadio,
|
||||
MenuItemCheckbox,
|
||||
MenuItem,
|
||||
} from "../../structures/ContextMenu";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import {
|
||||
getRoomNotifsState,
|
||||
setRoomNotifsState,
|
||||
ALL_MESSAGES,
|
||||
ALL_MESSAGES_LOUD,
|
||||
MENTIONS_ONLY,
|
||||
MUTE,
|
||||
} from "../../../RoomNotifs";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { Volume } from "../../../RoomNotifsTypes";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
import RoomListActions from "../../../actions/RoomListActions";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {ActionPayload} from "../../../dispatcher/payloads";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
showMessagePreview: boolean;
|
||||
isMinimized: boolean;
|
||||
tag: TagID;
|
||||
|
||||
// TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
notificationState: NotificationState;
|
||||
selected: boolean;
|
||||
notificationsMenuPosition: PartialDOMRect;
|
||||
generalMenuPosition: PartialDOMRect;
|
||||
}
|
||||
|
||||
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
|
||||
|
||||
const contextMenuBelow = (elementRect: PartialDOMRect) => {
|
||||
// align the context menu's icons with the icon which opened the context menu
|
||||
const left = elementRect.left + window.pageXOffset - 9;
|
||||
const top = elementRect.bottom + window.pageYOffset + 17;
|
||||
const chevronFace = ChevronFace.None;
|
||||
return {left, top, chevronFace};
|
||||
};
|
||||
|
||||
interface INotifOptionProps {
|
||||
active: boolean;
|
||||
iconClassName: string;
|
||||
label: string;
|
||||
onClick(ev: ButtonEvent);
|
||||
}
|
||||
|
||||
const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassName, label}) => {
|
||||
const classes = classNames({
|
||||
mx_RoomTile_contextMenu_activeRow: active,
|
||||
});
|
||||
|
||||
let activeIcon;
|
||||
if (active) {
|
||||
activeIcon = <span className="mx_IconizedContextMenu_icon mx_RoomTile_iconCheck" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemRadio className={classes} onClick={onClick} active={active} label={label}>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
{ activeIcon }
|
||||
</MenuItemRadio>
|
||||
);
|
||||
};
|
||||
|
||||
export default class RoomTile extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private roomTileRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
|
||||
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
|
||||
notificationsMenuPosition: null,
|
||||
generalMenuPosition: null,
|
||||
};
|
||||
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
private get showContextMenu(): boolean {
|
||||
return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite;
|
||||
}
|
||||
|
||||
private get showMessagePreview(): boolean {
|
||||
return !this.props.isMinimized && this.props.showMessagePreview;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
|
||||
if (this.state.selected) {
|
||||
this.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.props.room) {
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) {
|
||||
setImmediate(() => {
|
||||
this.scrollIntoView();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private scrollIntoView = () => {
|
||||
if (!this.roomTileRef.current) return;
|
||||
this.roomTileRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "auto",
|
||||
});
|
||||
};
|
||||
|
||||
private onTileMouseEnter = () => {
|
||||
this.setState({hover: true});
|
||||
};
|
||||
|
||||
private onTileMouseLeave = () => {
|
||||
this.setState({hover: false});
|
||||
};
|
||||
|
||||
private onTileClick = (ev: React.KeyboardEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
show_room_tile: true, // make sure the room is visible in the list
|
||||
room_id: this.props.room.roomId,
|
||||
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
||||
});
|
||||
};
|
||||
|
||||
private onActiveRoomUpdate = (isActive: boolean) => {
|
||||
this.setState({selected: isActive});
|
||||
};
|
||||
|
||||
private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({notificationsMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onCloseNotificationsMenu = () => {
|
||||
this.setState({notificationsMenuPosition: null});
|
||||
};
|
||||
|
||||
private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({generalMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
// If we don't have a context menu to show, ignore the action.
|
||||
if (!this.showContextMenu) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
generalMenuPosition: {
|
||||
left: ev.clientX,
|
||||
bottom: ev.clientY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseGeneralMenu = () => {
|
||||
this.setState({generalMenuPosition: null});
|
||||
};
|
||||
|
||||
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (tagId === DefaultTagID.Favourite) {
|
||||
const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
|
||||
const isFavourite = roomTags.includes(DefaultTagID.Favourite);
|
||||
const removeTag = isFavourite ? DefaultTagID.Favourite : DefaultTagID.LowPriority;
|
||||
const addTag = isFavourite ? null : DefaultTagID.Favourite;
|
||||
dis.dispatch(RoomListActions.tagRoom(
|
||||
MatrixClientPeg.get(),
|
||||
this.props.room,
|
||||
removeTag,
|
||||
addTag,
|
||||
undefined,
|
||||
0
|
||||
));
|
||||
} else {
|
||||
console.warn(`Unexpected tag ${tagId} applied to ${this.props.room.room_id}`);
|
||||
}
|
||||
|
||||
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
|
||||
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
||||
this.setState({generalMenuPosition: null}); // hide the menu
|
||||
}
|
||||
};
|
||||
|
||||
private onLeaveRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'leave_room',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuPosition: null}); // hide the menu
|
||||
};
|
||||
|
||||
private onForgetRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'forget_room',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuPosition: null}); // hide the menu
|
||||
};
|
||||
|
||||
private onOpenRoomSettings = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'open_room_settings',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuPosition: null}); // hide the menu
|
||||
};
|
||||
|
||||
private async saveNotifState(ev: ButtonEvent, newState: Volume) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
// get key before we go async and React discards the nativeEvent
|
||||
const key = (ev as React.KeyboardEvent).key;
|
||||
try {
|
||||
// TODO add local echo - https://github.com/vector-im/riot-web/issues/14280
|
||||
await setRoomNotifsState(this.props.room.roomId, newState);
|
||||
} catch (error) {
|
||||
// TODO: some form of error notification to the user to inform them that their state change failed.
|
||||
// See https://github.com/vector-im/riot-web/issues/14281
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (key === Key.ENTER) {
|
||||
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
||||
this.setState({notificationsMenuPosition: null}); // hide the menu
|
||||
}
|
||||
}
|
||||
|
||||
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
|
||||
private onClickAlertMe = ev => this.saveNotifState(ev, ALL_MESSAGES_LOUD);
|
||||
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
|
||||
private onClickMute = ev => this.saveNotifState(ev, MUTE);
|
||||
|
||||
private renderNotificationsMenu(isActive: boolean): React.ReactElement {
|
||||
if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived || !this.showContextMenu) {
|
||||
// the menu makes no sense in these cases so do not show one
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = getRoomNotifsState(this.props.room.roomId);
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.notificationsMenuPosition) {
|
||||
contextMenu = (
|
||||
<ContextMenu {...contextMenuBelow(this.state.notificationsMenuPosition)} onFinished={this.onCloseNotificationsMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<NotifOption
|
||||
label={_t("Use default")}
|
||||
active={state === ALL_MESSAGES}
|
||||
iconClassName="mx_RoomTile_iconBell"
|
||||
onClick={this.onClickAllNotifs}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("All messages")}
|
||||
active={state === ALL_MESSAGES_LOUD}
|
||||
iconClassName="mx_RoomTile_iconBellDot"
|
||||
onClick={this.onClickAlertMe}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("Mentions & Keywords")}
|
||||
active={state === MENTIONS_ONLY}
|
||||
iconClassName="mx_RoomTile_iconBellMentions"
|
||||
onClick={this.onClickMentions}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("None")}
|
||||
active={state === MUTE}
|
||||
iconClassName="mx_RoomTile_iconBellCrossed"
|
||||
onClick={this.onClickMute}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RoomTile_notificationsButton", {
|
||||
// Show bell icon for the default case too.
|
||||
mx_RoomTile_iconBell: state === ALL_MESSAGES,
|
||||
mx_RoomTile_iconBellDot: state === ALL_MESSAGES_LOUD,
|
||||
mx_RoomTile_iconBellMentions: state === MENTIONS_ONLY,
|
||||
mx_RoomTile_iconBellCrossed: state === MUTE,
|
||||
|
||||
// Only show the icon by default if the room is overridden to muted.
|
||||
// TODO: [FTUE Notifications] Probably need to detect global mute state
|
||||
mx_RoomTile_notificationsButton_show: state === MUTE,
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className={classes}
|
||||
onClick={this.onNotificationsMenuOpenClick}
|
||||
title={_t("Notification options")}
|
||||
isExpanded={!!this.state.notificationsMenuPosition}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderGeneralMenu(): React.ReactElement {
|
||||
if (!this.showContextMenu) return null; // no menu to show
|
||||
|
||||
const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
|
||||
|
||||
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
|
||||
const favouriteIconClassName = isFavorite ? "mx_RoomTile_iconFavorite" : "mx_RoomTile_iconStar";
|
||||
const favouriteLabelClassName = isFavorite ? "mx_RoomTile_contextMenu_activeRow" : "";
|
||||
const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite");
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.generalMenuPosition && this.props.tag === DefaultTagID.Archived) {
|
||||
contextMenu = (
|
||||
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList mx_RoomTile_contextMenu_redRow">
|
||||
<MenuItem onClick={this.onForgetRoomClick} label={_t("Leave Room")}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Forget Room")}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
} else if (this.state.generalMenuPosition) {
|
||||
contextMenu = (
|
||||
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<MenuItemCheckbox
|
||||
className={favouriteLabelClassName}
|
||||
onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}
|
||||
active={isFavorite}
|
||||
label={favouriteLabel}
|
||||
>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", favouriteIconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{favouriteLabel}</span>
|
||||
</MenuItemCheckbox>
|
||||
<MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconSettings" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList mx_RoomTile_contextMenu_redRow">
|
||||
<MenuItem onClick={this.onLeaveRoomClick} label={_t("Leave Room")}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_RoomTile_menuButton"
|
||||
onClick={this.onGeneralMenuOpenClick}
|
||||
title={_t("Room options")}
|
||||
isExpanded={!!this.state.generalMenuPosition}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactElement {
|
||||
const classes = classNames({
|
||||
'mx_RoomTile': true,
|
||||
'mx_RoomTile_selected': this.state.selected,
|
||||
'mx_RoomTile_hasMenuOpen': !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
|
||||
'mx_RoomTile_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const roomAvatar = <DecoratedRoomAvatar
|
||||
room={this.props.room}
|
||||
avatarSize={32}
|
||||
tag={this.props.tag}
|
||||
displayBadge={this.props.isMinimized}
|
||||
/>;
|
||||
|
||||
let badge: React.ReactNode;
|
||||
if (!this.props.isMinimized) {
|
||||
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
|
||||
badge = (
|
||||
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
|
||||
<NotificationBadge
|
||||
notification={this.state.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let name = this.props.room.name;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
let messagePreview = null;
|
||||
if (this.showMessagePreview) {
|
||||
// The preview store heavily caches this info, so should be safe to hammer.
|
||||
const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
|
||||
|
||||
// Only show the preview if there is one to show.
|
||||
if (text) {
|
||||
messagePreview = (
|
||||
<div className="mx_RoomTile_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_RoomTile_name": true,
|
||||
"mx_RoomTile_nameWithPreview": !!messagePreview,
|
||||
"mx_RoomTile_nameHasUnreadEvents": this.state.notificationState.isUnread,
|
||||
});
|
||||
|
||||
let nameContainer = (
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
{messagePreview}
|
||||
</div>
|
||||
);
|
||||
if (this.props.isMinimized) nameContainer = null;
|
||||
|
||||
let ariaLabel = name;
|
||||
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
|
||||
if (this.props.tag === DefaultTagID.Invite) {
|
||||
// append nothing
|
||||
} else if (this.state.notificationState.hasMentions) {
|
||||
ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
|
||||
count: this.state.notificationState.count,
|
||||
});
|
||||
} else if (this.state.notificationState.hasUnreadCount) {
|
||||
ariaLabel += " " + _t("%(count)s unread messages.", {
|
||||
count: this.state.notificationState.count,
|
||||
});
|
||||
} else if (this.state.notificationState.isUnread) {
|
||||
ariaLabel += " " + _t("Unread messages.");
|
||||
}
|
||||
|
||||
let ariaDescribedBy: string;
|
||||
if (this.showMessagePreview) {
|
||||
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
|
||||
}
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
if (this.props.isMinimized) {
|
||||
Button = AccessibleTooltipButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<Button
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
inputRef={ref}
|
||||
className={classes}
|
||||
onMouseEnter={this.onTileMouseEnter}
|
||||
onMouseLeave={this.onTileMouseLeave}
|
||||
onClick={this.onTileClick}
|
||||
onContextMenu={this.onContextMenu}
|
||||
role="treeitem"
|
||||
aria-label={ariaLabel}
|
||||
aria-selected={this.state.selected}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
title={this.props.isMinimized ? name : undefined}
|
||||
>
|
||||
{roomAvatar}
|
||||
{nameContainer}
|
||||
{badge}
|
||||
{this.renderGeneralMenu()}
|
||||
{this.renderNotificationsMenu(isActive)}
|
||||
</Button>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,323 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019, 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 React, { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import NotificationBadge, {
|
||||
INotificationState,
|
||||
NotificationColor,
|
||||
TagSpecificNotificationState
|
||||
} from "./NotificationBadge";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import RoomTileIcon from "./RoomTileIcon";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
showMessagePreview: boolean;
|
||||
isMinimized: boolean;
|
||||
tag: TagID;
|
||||
|
||||
// TODO: Allow falsifying counts (for invites and stuff)
|
||||
// TODO: Transparency? Was this ever used?
|
||||
// TODO: Incoming call boxes?
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
notificationState: INotificationState;
|
||||
selected: boolean;
|
||||
generalMenuDisplayed: boolean;
|
||||
}
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
private roomTileRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
|
||||
// TODO: Custom status
|
||||
// TODO: Lock icon
|
||||
// TODO: Presence indicator
|
||||
// TODO: e2e shields
|
||||
// TODO: Handle changes to room aesthetics (name, join rules, etc)
|
||||
// TODO: scrollIntoView?
|
||||
// TODO: hover, badge, etc
|
||||
// TODO: isSelected for hover effects
|
||||
// TODO: Context menu
|
||||
// TODO: a11y
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
|
||||
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
|
||||
generalMenuDisplayed: false,
|
||||
};
|
||||
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.props.room) {
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private onTileMouseEnter = () => {
|
||||
this.setState({hover: true});
|
||||
};
|
||||
|
||||
private onTileMouseLeave = () => {
|
||||
this.setState({hover: false});
|
||||
};
|
||||
|
||||
private onTileClick = (ev: React.KeyboardEvent) => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
// TODO: Support show_room_tile in new room list
|
||||
show_room_tile: true, // make sure the room is visible in the list
|
||||
room_id: this.props.room.roomId,
|
||||
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
||||
});
|
||||
};
|
||||
|
||||
private onActiveRoomUpdate = (isActive: boolean) => {
|
||||
this.setState({selected: isActive});
|
||||
};
|
||||
|
||||
private onGeneralMenuOpenClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({generalMenuDisplayed: true});
|
||||
};
|
||||
|
||||
private onCloseGeneralMenu = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({generalMenuDisplayed: false});
|
||||
};
|
||||
|
||||
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211
|
||||
// TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210
|
||||
};
|
||||
|
||||
private onLeaveRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'leave_room',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuDisplayed: false}); // hide the menu
|
||||
};
|
||||
|
||||
private onOpenRoomSettings = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'open_room_settings',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuDisplayed: false}); // hide the menu
|
||||
};
|
||||
|
||||
private renderGeneralMenu(): React.ReactElement {
|
||||
if (this.props.isMinimized) return null; // no menu when minimized
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.generalMenuDisplayed) {
|
||||
// The context menu appears within the list, so use the room tile as a reference point
|
||||
const elementRect = this.roomTileRef.current.getBoundingClientRect();
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height + 8}
|
||||
onFinished={this.onCloseGeneralMenu}
|
||||
>
|
||||
<div
|
||||
className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"
|
||||
style={{width: elementRect.width}}
|
||||
>
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
|
||||
<span>{_t("Favourite")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown" />
|
||||
<span>{_t("Low Priority")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onOpenRoomSettings}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
|
||||
<span>{_t("Settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li className="mx_RoomTile2_contextMenu_redRow">
|
||||
<AccessibleButton onClick={this.onLeaveRoomClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
|
||||
<span>{_t("Leave Room")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_RoomTile2_menuButton"
|
||||
onClick={this.onGeneralMenuOpenClick}
|
||||
inputRef={this.generalMenuButtonRef}
|
||||
label={_t("Room options")}
|
||||
isExpanded={this.state.generalMenuDisplayed}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// TODO: Collapsed state
|
||||
// TODO: Invites
|
||||
// TODO: a11y proper
|
||||
// TODO: Render more than bare minimum
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomTile2': true,
|
||||
'mx_RoomTile2_selected': this.state.selected,
|
||||
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed,
|
||||
'mx_RoomTile2_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const badge = (
|
||||
<NotificationBadge
|
||||
notification={this.state.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
);
|
||||
|
||||
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||
let name = this.props.room.name;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
// TODO: Support collapsed state properly
|
||||
// TODO: Tooltip?
|
||||
|
||||
let messagePreview = null;
|
||||
if (this.props.showMessagePreview && !this.props.isMinimized) {
|
||||
// The preview store heavily caches this info, so should be safe to hammer.
|
||||
const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
|
||||
|
||||
// Only show the preview if there is one to show.
|
||||
if (text) {
|
||||
messagePreview = (
|
||||
<div className="mx_RoomTile2_messagePreview">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_RoomTile2_name": true,
|
||||
"mx_RoomTile2_nameWithPreview": !!messagePreview,
|
||||
"mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
|
||||
});
|
||||
|
||||
let nameContainer = (
|
||||
<div className="mx_RoomTile2_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
{messagePreview}
|
||||
</div>
|
||||
);
|
||||
if (this.props.isMinimized) nameContainer = null;
|
||||
|
||||
const avatarSize = 32;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
inputRef={ref}
|
||||
className={classes}
|
||||
onMouseEnter={this.onTileMouseEnter}
|
||||
onMouseLeave={this.onTileMouseLeave}
|
||||
onClick={this.onTileClick}
|
||||
role="treeitem"
|
||||
>
|
||||
<div className="mx_RoomTile2_avatarContainer">
|
||||
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} />
|
||||
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
|
||||
</div>
|
||||
{nameContainer}
|
||||
<div className="mx_RoomTile2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
{this.renderGeneralMenu()}
|
||||
</AccessibleButton>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -22,6 +22,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { isPresenceEnabled } from "../../../utils/presence";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
|
||||
enum Icon {
|
||||
// Note: the names here are used in CSS class names
|
||||
|
@ -32,9 +34,21 @@ enum Icon {
|
|||
PresenceOffline = "OFFLINE",
|
||||
}
|
||||
|
||||
function tooltipText(variant: Icon) {
|
||||
switch (variant) {
|
||||
case Icon.Globe:
|
||||
return _t("This room is public");
|
||||
case Icon.PresenceOnline:
|
||||
return _t("Online");
|
||||
case Icon.PresenceAway:
|
||||
return _t("Away");
|
||||
case Icon.PresenceOffline:
|
||||
return _t("Offline");
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
tag: TagID;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -122,10 +136,11 @@ export default class RoomTileIcon extends React.Component<IProps, IState> {
|
|||
private calculateIcon(): Icon {
|
||||
let icon = Icon.None;
|
||||
|
||||
if (this.props.tag === DefaultTagID.DM && this.props.room.getJoinedMemberCount() === 2) {
|
||||
// We look at the DMRoomMap and not the tag here so that we don't exclude DMs in Favourites
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
|
||||
if (otherUserId && this.props.room.getJoinedMemberCount() === 2) {
|
||||
// Track presence, if available
|
||||
if (isPresenceEnabled()) {
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
|
||||
if (otherUserId) {
|
||||
this.dmUser = MatrixClientPeg.get().getUser(otherUserId);
|
||||
icon = this.getPresenceIcon();
|
||||
|
@ -145,6 +160,9 @@ export default class RoomTileIcon extends React.Component<IProps, IState> {
|
|||
public render(): React.ReactElement {
|
||||
if (this.state.icon === Icon.None) return null;
|
||||
|
||||
return <span className={`mx_RoomTileIcon mx_RoomTileIcon_${this.state.icon.toLowerCase()}`} />;
|
||||
return <TextWithTooltip
|
||||
tooltip={tooltipText(this.state.icon)}
|
||||
class={`mx_RoomTileIcon mx_RoomTileIcon_${this.state.icon.toLowerCase()}`}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {ContextMenu} from "../../structures/ContextMenu";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
|
||||
// We sit in a context menu, so this should be given to the context menu.
|
||||
|
@ -409,14 +410,14 @@ export default class Stickerpicker extends React.Component {
|
|||
} else {
|
||||
// Show show-stickers button
|
||||
stickersButton =
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
id='stickersButton'
|
||||
key="controls_show_stickers"
|
||||
className="mx_MessageComposer_button mx_MessageComposer_stickers"
|
||||
onClick={this._onShowStickersClick}
|
||||
title={_t("Show Stickers")}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
</AccessibleTooltipButton>;
|
||||
}
|
||||
return <React.Fragment>
|
||||
{ stickersButton }
|
||||
|
|
119
src/components/views/rooms/TemporaryTile.tsx
Normal file
119
src/components/views/rooms/TemporaryTile.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
RovingAccessibleButton,
|
||||
RovingAccessibleTooltipButton,
|
||||
RovingTabIndexWrapper
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
isSelected: boolean;
|
||||
displayName: string;
|
||||
avatar: React.ReactElement;
|
||||
notificationState: NotificationState;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
// TODO: Remove with community invites in the room list: https://github.com/vector-im/riot-web/issues/14456
|
||||
export default class TemporaryTile extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onTileMouseEnter = () => {
|
||||
this.setState({hover: true});
|
||||
};
|
||||
|
||||
private onTileMouseLeave = () => {
|
||||
this.setState({hover: false});
|
||||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// XXX: We copy classes because it's easier
|
||||
const classes = classNames({
|
||||
'mx_RoomTile': true,
|
||||
'mx_TemporaryTile': true,
|
||||
'mx_RoomTile_selected': this.props.isSelected,
|
||||
'mx_RoomTile_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const badge = (
|
||||
<NotificationBadge
|
||||
notification={this.props.notificationState}
|
||||
forceCount={false}
|
||||
/>
|
||||
);
|
||||
|
||||
let name = this.props.displayName;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_RoomTile_name": true,
|
||||
"mx_RoomTile_nameHasUnreadEvents": this.props.notificationState.isUnread,
|
||||
});
|
||||
|
||||
let nameContainer = (
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (this.props.isMinimized) nameContainer = null;
|
||||
|
||||
let Button = RovingAccessibleButton;
|
||||
if (this.props.isMinimized) {
|
||||
Button = RovingAccessibleTooltipButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
className={classes}
|
||||
onMouseEnter={this.onTileMouseEnter}
|
||||
onMouseLeave={this.onTileMouseLeave}
|
||||
onClick={this.props.onClick}
|
||||
role="treeitem"
|
||||
title={this.props.isMinimized ? name : undefined}
|
||||
>
|
||||
<div className="mx_RoomTile_avatarContainer">
|
||||
{this.props.avatar}
|
||||
</div>
|
||||
{nameContainer}
|
||||
<div className="mx_RoomTile_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 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, {useContext, useEffect, useMemo, useState, useCallback} from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
const UserOnlineDot = ({userId}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const user = useMemo(() => cli.getUser(userId), [cli, userId]);
|
||||
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
|
||||
// Recheck if the user or client changes
|
||||
useEffect(() => {
|
||||
setIsOnline(user && (user.currentlyActive || user.presence === "online"));
|
||||
}, [cli, user]);
|
||||
// Recheck also if we receive a User.currentlyActive event
|
||||
const currentlyActiveHandler = useCallback((ev) => {
|
||||
const content = ev.getContent();
|
||||
setIsOnline(content.currently_active || content.presence === "online");
|
||||
}, []);
|
||||
useEventEmitter(user, "User.currentlyActive", currentlyActiveHandler);
|
||||
useEventEmitter(user, "User.presence", currentlyActiveHandler);
|
||||
|
||||
return isOnline ? <span className="mx_UserOnlineDot" /> : null;
|
||||
};
|
||||
|
||||
UserOnlineDot.propTypes = {
|
||||
userId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default UserOnlineDot;
|
|
@ -48,7 +48,6 @@ const AvatarSetting = ({avatarUrl, avatarAltText, avatarName, uploadAvatar, remo
|
|||
if (uploadAvatar) {
|
||||
// insert an empty div to be the host for a css mask containing the upload.svg
|
||||
uploadAvatarBtn = <AccessibleButton onClick={uploadAvatar} kind="primary">
|
||||
<div> </div>
|
||||
{_t("Upload")}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import * as sdk from '../../../index';
|
||||
import Modal from '../../../Modal';
|
||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
|
@ -121,6 +122,7 @@ export default class EventIndexPanel extends React.Component {
|
|||
render() {
|
||||
let eventIndexingSettings = null;
|
||||
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
if (EventIndexPeg.get() !== null) {
|
||||
eventIndexingSettings = (
|
||||
|
@ -165,11 +167,13 @@ export default class EventIndexPanel extends React.Component {
|
|||
eventIndexingSettings = (
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{
|
||||
_t( "Riot is missing some components required for securely " +
|
||||
_t( "%(brand)s is missing some components required for securely " +
|
||||
"caching encrypted messages locally. If you'd like to " +
|
||||
"experiment with this feature, build a custom Riot Desktop " +
|
||||
"experiment with this feature, build a custom %(brand)s Desktop " +
|
||||
"with <nativeLink>search components added</nativeLink>.",
|
||||
{},
|
||||
{
|
||||
brand,
|
||||
},
|
||||
{
|
||||
'nativeLink': (sub) => <a href={nativeLink} target="_blank"
|
||||
rel="noreferrer noopener">{sub}</a>,
|
||||
|
@ -182,12 +186,14 @@ export default class EventIndexPanel extends React.Component {
|
|||
eventIndexingSettings = (
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{
|
||||
_t( "Riot can't securely cache encrypted messages locally " +
|
||||
"while running in a web browser. Use <riotLink>Riot Desktop</riotLink> " +
|
||||
_t( "%(brand)s can't securely cache encrypted messages locally " +
|
||||
"while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " +
|
||||
"for encrypted messages to appear in search results.",
|
||||
{},
|
||||
{
|
||||
'riotLink': (sub) => <a href="https://riot.im/download/desktop"
|
||||
brand,
|
||||
},
|
||||
{
|
||||
'desktopLink': (sub) => <a href="https://riot.im/download/desktop"
|
||||
target="_blank" rel="noreferrer noopener">{sub}</a>,
|
||||
},
|
||||
)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue