Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/querystring
Conflicts: package.json src/@types/global.d.ts src/components/views/elements/AppTile.js src/utils/HostingLink.js yarn.lock
This commit is contained in:
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
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.
|
||||
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";
|
||||
|
||||
export default class AutoHideScrollbar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._collectContainerRef = this._collectContainerRef.bind(this);
|
||||
}
|
||||
|
||||
_collectContainerRef(ref) {
|
||||
if (ref && !this.containerRef) {
|
||||
this.containerRef = ref;
|
||||
}
|
||||
if (this.props.wrappedRef) {
|
||||
this.props.wrappedRef(ref);
|
||||
}
|
||||
}
|
||||
|
||||
getScrollTop() {
|
||||
return this.containerRef.scrollTop;
|
||||
}
|
||||
|
||||
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}
|
||||
>
|
||||
{ this.props.children }
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
69
src/components/structures/AutoHideScrollbar.tsx
Normal file
69
src/components/structures/AutoHideScrollbar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
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.
|
||||
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, { HTMLAttributes, WheelEvent } from "react";
|
||||
|
||||
interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onScroll"> {
|
||||
className?: string;
|
||||
onScroll?: (event: Event) => void;
|
||||
onWheel?: (event: WheelEvent) => void;
|
||||
style?: React.CSSProperties;
|
||||
tabIndex?: number;
|
||||
wrappedRef?: (ref: HTMLDivElement) => void;
|
||||
}
|
||||
|
||||
export default class AutoHideScrollbar extends React.Component<IProps> {
|
||||
private containerRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.containerRef.current && this.props.onScroll) {
|
||||
// Using the passive option to not block the main thread
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
||||
this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true });
|
||||
}
|
||||
|
||||
if (this.props.wrappedRef) {
|
||||
this.props.wrappedRef(this.containerRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.containerRef.current && this.props.onScroll) {
|
||||
this.containerRef.current.removeEventListener("scroll", this.props.onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
public getScrollTop(): number {
|
||||
return this.containerRef.current.scrollTop;
|
||||
}
|
||||
|
||||
public render() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props;
|
||||
|
||||
return (<div
|
||||
{...otherProps}
|
||||
ref={this.containerRef}
|
||||
style={style}
|
||||
className={["mx_AutoHideScrollbar", className].join(" ")}
|
||||
onWheel={onWheel}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{ children }
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'CompatibilityPage',
|
||||
propTypes: {
|
||||
onAccept: PropTypes.func,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onAccept: function() {}, // NOP
|
||||
};
|
||||
},
|
||||
|
||||
onAccept: function() {
|
||||
this.props.onAccept();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
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(
|
||||
"Riot uses many advanced browser features, some of which are not available " +
|
||||
"or experimental in your current browser.",
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
'Please install <chromeLink>Chrome</chromeLink>, <firefoxLink>Firefox</firefoxLink>, ' +
|
||||
'or <safariLink>Safari</safariLink> for the best experience.',
|
||||
{},
|
||||
{
|
||||
'chromeLink': (sub) => <a href="https://www.google.com/chrome">{sub}</a>,
|
||||
'firefoxLink': (sub) => <a href="https://firefox.com">{sub}</a>,
|
||||
'safariLink': (sub) => <a href="https://apple.com/safari">{sub}</a>,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
"With your current browser, the look and feel of the application may be " +
|
||||
"completely incorrect, and some or all features may not function. " +
|
||||
"If you want to try it anyway you can continue, but you are on your own in terms " +
|
||||
"of any issues you may encounter!",
|
||||
) }
|
||||
</p>
|
||||
<button onClick={this.onAccept}>
|
||||
{ _t("I understand the risks and wish to continue") }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -16,13 +16,14 @@ 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 {Key} from "../../Keyboard";
|
||||
import * as sdk from "../../index";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import React, { CSSProperties, RefObject, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Key } from "../../Keyboard";
|
||||
import { Writeable } from "../../@types/common";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
||||
// 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 +31,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 +44,72 @@ 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",
|
||||
}
|
||||
|
||||
export 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;
|
||||
wrapperClassName?: string;
|
||||
|
||||
// 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
|
||||
};
|
||||
@replaceableComponent("structures.ContextMenu")
|
||||
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 +117,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 +134,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 +157,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,29 +204,31 @@ 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) => {
|
||||
// don't let keyboard handling escape the context menu
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!this.props.managed) {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.props.onFinished();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
@@ -200,19 +239,21 @@ export class ContextMenu extends React.Component {
|
||||
switch (ev.key) {
|
||||
case Key.TAB:
|
||||
case Key.ESCAPE:
|
||||
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />
|
||||
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;
|
||||
@@ -220,14 +261,12 @@ export class ContextMenu extends React.Component {
|
||||
|
||||
if (handled) {
|
||||
// consume all other keys in context menu
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
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,24 +275,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 padding = 10;
|
||||
|
||||
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;
|
||||
@@ -264,6 +303,7 @@ export class ContextMenu extends React.Component {
|
||||
// If we know the dimensions of the context menu, adjust its position
|
||||
// such that it does not leave the (padded) window.
|
||||
if (contextMenuRect) {
|
||||
const padding = 10;
|
||||
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
|
||||
}
|
||||
|
||||
@@ -282,13 +322,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 +359,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={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
||||
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,118 +389,77 @@ 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: Pick<DOMRect, "right" | "top" | "height">, 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
|
||||
return {left, top, chevronOffset};
|
||||
return { left, top, chevronOffset };
|
||||
};
|
||||
|
||||
// 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 };
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
|
||||
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
const buttonBottom = elementRect.bottom + window.pageYOffset;
|
||||
const buttonTop = elementRect.top + window.pageYOffset;
|
||||
// Align the right edge of the menu to the right edge of the button
|
||||
menuOptions.right = window.innerWidth - buttonRight;
|
||||
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
|
||||
// Align the menu vertically on whichever side of the button has more space available.
|
||||
if (buttonBottom < window.innerHeight / 2) {
|
||||
menuOptions.top = buttonBottom;
|
||||
if (buttonBottom < UIStore.instance.windowHeight / 2) {
|
||||
menuOptions.top = buttonBottom + vPadding;
|
||||
} else {
|
||||
menuOptions.bottom = window.innerHeight - buttonTop;
|
||||
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
|
||||
}
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
export const useContextMenu = () => {
|
||||
const button = useRef(null);
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
||||
// and always above elementRect
|
||||
export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
const buttonBottom = elementRect.bottom + window.pageYOffset;
|
||||
const buttonTop = elementRect.top + window.pageYOffset;
|
||||
// Align the right edge of the menu to the right edge of the button
|
||||
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
|
||||
// Align the menu vertically on whichever side of the button has more space available.
|
||||
if (buttonBottom < UIStore.instance.windowHeight / 2) {
|
||||
menuOptions.top = buttonBottom + vPadding;
|
||||
} else {
|
||||
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
|
||||
}
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
|
||||
// and always above elementRect
|
||||
export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonLeft = elementRect.left + window.pageXOffset;
|
||||
const buttonTop = elementRect.top + window.pageYOffset;
|
||||
// Align the left edge of the menu to the left edge of the button
|
||||
menuOptions.left = buttonLeft;
|
||||
// Align the menu vertically above the menu
|
||||
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
|
||||
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
||||
const button = useRef<T>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = () => {
|
||||
setIsOpen(true);
|
||||
@@ -457,6 +471,7 @@ export const useContextMenu = () => {
|
||||
return [isOpen, button, open, close, setIsOpen];
|
||||
};
|
||||
|
||||
@replaceableComponent("structures.LegacyContextMenu")
|
||||
export default class LegacyContextMenu extends ContextMenu {
|
||||
render() {
|
||||
return this.renderMenu(false);
|
||||
@@ -483,5 +498,15 @@ export function createMenu(ElementClass, props) {
|
||||
|
||||
ReactDOM.render(menu, getOrCreateContainer());
|
||||
|
||||
return {close: onFinished};
|
||||
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";
|
||||
@@ -18,10 +18,12 @@ import React from 'react';
|
||||
import CustomRoomTagStore from '../../stores/CustomRoomTagStore';
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import classNames from 'classnames';
|
||||
import * as FormattingUtils from '../../utils/FormattingUtils';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("structures.CustomRoomTagPanel")
|
||||
class CustomRoomTagPanel extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -32,7 +34,7 @@ class CustomRoomTagPanel extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
this._tagStoreToken = CustomRoomTagStore.addListener(() => {
|
||||
this.setState({tags: CustomRoomTagStore.getSortedTags()});
|
||||
this.setState({ tags: CustomRoomTagStore.getSortedTags() });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,7 +64,7 @@ class CustomRoomTagPanel extends React.Component {
|
||||
|
||||
class CustomRoomTagTile extends React.Component {
|
||||
onClick = () => {
|
||||
dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name});
|
||||
dis.dispatch({ action: 'select_custom_room_tag', tag: this.props.tag.name });
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -72,17 +74,17 @@ class CustomRoomTagTile extends React.Component {
|
||||
const tag = this.props.tag;
|
||||
const avatarHeight = 40;
|
||||
const className = classNames({
|
||||
CustomRoomTagPanel_tileSelected: tag.selected,
|
||||
"CustomRoomTagPanel_tileSelected": tag.selected,
|
||||
});
|
||||
const name = tag.name;
|
||||
const badge = tag.badge;
|
||||
const badgeNotifState = tag.badgeNotifState;
|
||||
let badgeElement;
|
||||
if (badge) {
|
||||
if (badgeNotifState) {
|
||||
const badgeClasses = classNames({
|
||||
"mx_TagTile_badge": true,
|
||||
"mx_TagTile_badgeHighlight": badge.highlight,
|
||||
"mx_TagTile_badgeHighlight": badgeNotifState.hasMentions,
|
||||
});
|
||||
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
|
||||
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badgeNotifState.count)}</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,15 +16,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import request from 'browser-request';
|
||||
import { _t } from '../../languageHandler';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import dis from '../../dispatcher';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import classnames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
@@ -43,8 +41,8 @@ export default class EmbeddedPage extends React.PureComponent {
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._dispatcherRef = null;
|
||||
|
||||
|
||||
@@ -16,46 +16,66 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {Filter} from 'matrix-js-sdk';
|
||||
import * as sdk from '../../index';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import { Filter } from 'matrix-js-sdk/src/filter';
|
||||
import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
|
||||
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
|
||||
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import EventIndexPeg from "../../indexing/EventIndexPeg";
|
||||
import { _t } from '../../languageHandler';
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuildsNotice";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
import ResizeNotifier from '../../utils/ResizeNotifier';
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { TileShape } from '../views/rooms/EventTile';
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
timelineSet: EventTimelineSet;
|
||||
}
|
||||
|
||||
/*
|
||||
* Component which shows the filtered file using a TimelinePanel
|
||||
*/
|
||||
const FilePanel = createReactClass({
|
||||
displayName: 'FilePanel',
|
||||
@replaceableComponent("structures.FilePanel")
|
||||
class FilePanel extends React.Component<IProps, IState> {
|
||||
// This is used to track if a decrypted event was a live event and should be
|
||||
// added to the timeline.
|
||||
decryptingEvents: new Set(),
|
||||
private decryptingEvents = new Set<string>();
|
||||
public noRoom: boolean;
|
||||
|
||||
propTypes: {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
},
|
||||
state = {
|
||||
timelineSet: null,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
timelineSet: null,
|
||||
};
|
||||
},
|
||||
|
||||
onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
|
||||
if (room.roomId !== this.props.roomId) return;
|
||||
private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: true, removed: true, data: any): void => {
|
||||
if (room?.roomId !== this.props?.roomId) return;
|
||||
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
client.decryptEventIfNeeded(ev);
|
||||
|
||||
if (ev.isBeingDecrypted()) {
|
||||
this.decryptingEvents.add(ev.getId());
|
||||
} else {
|
||||
this.addEncryptedLiveEvent(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onEventDecrypted(ev, err) {
|
||||
private onEventDecrypted = (ev: MatrixEvent, err?: any): void => {
|
||||
if (ev.getRoomId() !== this.props.roomId) return;
|
||||
const eventId = ev.getId();
|
||||
|
||||
@@ -63,9 +83,9 @@ const FilePanel = createReactClass({
|
||||
if (err) return;
|
||||
|
||||
this.addEncryptedLiveEvent(ev);
|
||||
},
|
||||
};
|
||||
|
||||
addEncryptedLiveEvent(ev, toStartOfTimeline) {
|
||||
public addEncryptedLiveEvent(ev: MatrixEvent): void {
|
||||
if (!this.state.timelineSet) return;
|
||||
|
||||
const timeline = this.state.timelineSet.getLiveTimeline();
|
||||
@@ -77,9 +97,9 @@ const FilePanel = createReactClass({
|
||||
if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
|
||||
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
public async componentDidMount(): Promise<void> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
await this.updateTimelineSet(this.props.roomId);
|
||||
@@ -98,9 +118,9 @@ const FilePanel = createReactClass({
|
||||
client.on('Room.timeline', this.onRoomTimeline);
|
||||
client.on('Event.decrypted', this.onEventDecrypted);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client === null) return;
|
||||
|
||||
@@ -110,9 +130,9 @@ const FilePanel = createReactClass({
|
||||
client.removeListener('Room.timeline', this.onRoomTimeline);
|
||||
client.removeListener('Event.decrypted', this.onEventDecrypted);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async fetchFileEventsServer(room) {
|
||||
public async fetchFileEventsServer(room: Room): Promise<EventTimelineSet> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const filter = new Filter(client.credentials.userId);
|
||||
@@ -134,9 +154,13 @@ const FilePanel = createReactClass({
|
||||
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
|
||||
|
||||
return timelineSet;
|
||||
},
|
||||
}
|
||||
|
||||
onPaginationRequest(timelineWindow, direction, limit) {
|
||||
private onPaginationRequest = (
|
||||
timelineWindow: TimelineWindow,
|
||||
direction: Direction,
|
||||
limit: number,
|
||||
): Promise<boolean> => {
|
||||
const client = MatrixClientPeg.get();
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const roomId = this.props.roomId;
|
||||
@@ -152,9 +176,9 @@ const FilePanel = createReactClass({
|
||||
} else {
|
||||
return timelineWindow.paginate(direction, limit);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async updateTimelineSet(roomId: string) {
|
||||
public async updateTimelineSet(roomId: string): Promise<void> {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(roomId);
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
@@ -188,53 +212,76 @@ const FilePanel = createReactClass({
|
||||
} else {
|
||||
console.error("Failed to add filtered timelineSet for FilePanel as no room!");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
public render() {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
|
||||
return <BaseCard
|
||||
className="mx_FilePanel mx_RoomView_messageListWrapper"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<div className="mx_RoomView_empty">
|
||||
{ _t("You must <a>register</a> to use this functionality",
|
||||
{},
|
||||
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
|
||||
}
|
||||
{ _t("You must <a>register</a> to use this functionality",
|
||||
{},
|
||||
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
|
||||
}
|
||||
</div>
|
||||
</div>;
|
||||
</BaseCard>;
|
||||
} else if (this.noRoom) {
|
||||
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
|
||||
return <BaseCard
|
||||
className="mx_FilePanel mx_RoomView_messageListWrapper"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<div className="mx_RoomView_empty">{ _t("You must join the room to see its files") }</div>
|
||||
</div>;
|
||||
</BaseCard>;
|
||||
}
|
||||
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
||||
const emptyState = (<div className="mx_RightPanel_empty mx_FilePanel_empty">
|
||||
<h2>{_t('No files visible in this room')}</h2>
|
||||
<p>{_t('Attach files from chat or just drag and drop them anywhere in a room.')}</p>
|
||||
</div>);
|
||||
|
||||
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
|
||||
|
||||
if (this.state.timelineSet) {
|
||||
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
|
||||
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
|
||||
return (
|
||||
<div className="mx_FilePanel" role="tabpanel">
|
||||
<TimelinePanel key={"filepanel_" + this.props.roomId}
|
||||
<BaseCard
|
||||
className="mx_FilePanel"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
withoutScrollContainer
|
||||
>
|
||||
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview = {false}
|
||||
onPaginationRequest={this.onPaginationRequest}
|
||||
tileShape="file_grid"
|
||||
tileShape={TileShape.FileGrid}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={_t('There are no visible files in this room')}
|
||||
empty={emptyState}
|
||||
/>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="mx_FilePanel" role="tabpanel">
|
||||
<Loader />
|
||||
</div>
|
||||
<BaseCard
|
||||
className="mx_FilePanel"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<Spinner />
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default FilePanel;
|
||||
@@ -16,7 +16,9 @@ limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("structures.GenericErrorPage")
|
||||
export default class GenericErrorPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
title: PropTypes.object.isRequired, // jsx for title
|
||||
|
||||
166
src/components/structures/GroupFilterPanel.js
Normal file
166
src/components/structures/GroupFilterPanel.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright 2017, 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.
|
||||
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 GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
|
||||
|
||||
import GroupActions from '../../actions/GroupActions';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import UserTagTile from "../views/elements/UserTagTile";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("structures.GroupFilterPanel")
|
||||
class GroupFilterPanel extends React.Component {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
state = {
|
||||
orderedTags: [],
|
||||
selectedTags: [],
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.on("sync", this._onClientSync);
|
||||
|
||||
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
orderedTags: GroupFilterOrderStore.getOrderedTags() || [],
|
||||
selectedTags: GroupFilterOrderStore.getSelectedTags(),
|
||||
});
|
||||
});
|
||||
// This could be done by anything with a matrix client
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.removeListener("sync", this._onClientSync);
|
||||
if (this._groupFilterOrderStoreToken) {
|
||||
this._groupFilterOrderStoreToken.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_onGroupMyMembership = () => {
|
||||
if (this.unmounted) return;
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
};
|
||||
|
||||
_onClientSync = (syncState, prevState) => {
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
if (reconnected) {
|
||||
// Load joined groups
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
}
|
||||
};
|
||||
|
||||
onClick = e => {
|
||||
// only dispatch if its not a no-op
|
||||
if (this.state.selectedTags.length > 0) {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
}
|
||||
};
|
||||
|
||||
onClearFilterClick = ev => {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
};
|
||||
|
||||
renderGlobalIcon() {
|
||||
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserTagTile />
|
||||
<hr className="mx_GroupFilterPanel_divider" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
|
||||
const tags = this.state.orderedTags.map((tag, index) => {
|
||||
return <DNDTagTile
|
||||
key={tag}
|
||||
tag={tag}
|
||||
index={index}
|
||||
selected={this.state.selectedTags.includes(tag)}
|
||||
/>;
|
||||
});
|
||||
|
||||
const itemsSelected = this.state.selectedTags.length > 0;
|
||||
const classes = classNames('mx_GroupFilterPanel', {
|
||||
mx_GroupFilterPanel_items_selected: itemsSelected,
|
||||
});
|
||||
|
||||
let betaDot;
|
||||
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
|
||||
betaDot = <div className="mx_BetaDot" />;
|
||||
}
|
||||
|
||||
let createButton = (
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Communities")}
|
||||
action="toggle_my_groups"
|
||||
className="mx_TagTile mx_TagTile_plus">
|
||||
{ betaDot }
|
||||
</ActionButton>
|
||||
);
|
||||
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
createButton = (
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Create community")}
|
||||
action="view_create_group"
|
||||
className="mx_TagTile mx_TagTile_plus" />
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classes} onClick={this.onClearFilterClick}>
|
||||
<AutoHideScrollbar
|
||||
className="mx_GroupFilterPanel_scroller"
|
||||
onClick={this.onClick}
|
||||
>
|
||||
<div className="mx_GroupFilterPanel_tagTileContainer">
|
||||
{ this.renderGlobalIcon() }
|
||||
{ tags }
|
||||
<div>
|
||||
{ createButton }
|
||||
</div>
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
export default GroupFilterPanel;
|
||||
@@ -17,11 +17,10 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { getHostingLink } from '../../utils/HostingLink';
|
||||
import { sanitizedHtmlNode } from '../../HtmlUtils';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
@@ -35,20 +34,22 @@ import classnames from 'classnames';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
import FlairStore from '../../stores/FlairStore';
|
||||
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
|
||||
import {Group} from "matrix-js-sdk";
|
||||
import {allSettled, sleep} from "../../utils/promise";
|
||||
import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { Group } from "matrix-js-sdk/src/models/group";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
`<h1>HTML for your community's page</h1>
|
||||
`<h1>HTML for your community's page</h1>
|
||||
<p>
|
||||
Use the long description to introduce new members to the community, or distribute
|
||||
some important <a href="foo">links</a>
|
||||
</p>
|
||||
<p>
|
||||
You can even use 'img' tags
|
||||
You can even add images with Matrix URLs <img src="mxc://url" />
|
||||
</p>
|
||||
`);
|
||||
|
||||
@@ -70,10 +71,8 @@ const UserSummaryType = PropTypes.shape({
|
||||
}).isRequired,
|
||||
});
|
||||
|
||||
const CategoryRoomList = createReactClass({
|
||||
displayName: 'CategoryRoomList',
|
||||
|
||||
props: {
|
||||
class CategoryRoomList extends React.Component {
|
||||
static propTypes = {
|
||||
rooms: PropTypes.arrayOf(RoomSummaryType).isRequired,
|
||||
category: PropTypes.shape({
|
||||
profile: PropTypes.shape({
|
||||
@@ -84,15 +83,15 @@ const CategoryRoomList = createReactClass({
|
||||
|
||||
// Whether the list should be editable
|
||||
editing: PropTypes.bool.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onAddRoomsToSummaryClicked: function(ev) {
|
||||
onAddRoomsToSummaryClicked = (ev) => {
|
||||
ev.preventDefault();
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, {
|
||||
title: _t('Add rooms to the community summary'),
|
||||
description: _t("Which rooms would you like to add to this summary?"),
|
||||
placeholder: _t("Room name or alias"),
|
||||
placeholder: _t("Room name or address"),
|
||||
button: _t("Add to summary"),
|
||||
pickerType: 'room',
|
||||
validAddressTypes: ['mx-room-id'],
|
||||
@@ -100,7 +99,7 @@ const CategoryRoomList = createReactClass({
|
||||
onFinished: (success, addrs) => {
|
||||
if (!success) return;
|
||||
const errorList = [];
|
||||
allSettled(addrs.map((addr) => {
|
||||
Promise.allSettled(addrs.map((addr) => {
|
||||
return GroupStore
|
||||
.addRoomToGroupSummary(this.props.groupId, addr.address)
|
||||
.catch(() => { errorList.push(addr.address); });
|
||||
@@ -111,26 +110,27 @@ const CategoryRoomList = createReactClass({
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to add the following room to the group summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following rooms to the summary of %(groupId)s:",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
});
|
||||
'',
|
||||
ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following rooms to the summary of %(groupId)s:",
|
||||
{ groupId: this.props.groupId },
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
render() {
|
||||
const addButton = this.props.editing ?
|
||||
(<AccessibleButton className="mx_GroupView_featuredThings_addButton"
|
||||
onClick={this.onAddRoomsToSummaryClicked}
|
||||
>
|
||||
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
|
||||
<img src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
|
||||
<div className="mx_GroupView_featuredThings_addButton_label">
|
||||
{ _t('Add a Room') }
|
||||
</div>
|
||||
@@ -147,27 +147,25 @@ const CategoryRoomList = createReactClass({
|
||||
let catHeader = <div />;
|
||||
if (this.props.category && this.props.category.profile) {
|
||||
catHeader = <div className="mx_GroupView_featuredThings_category">
|
||||
{ this.props.category.profile.name }
|
||||
</div>;
|
||||
{ this.props.category.profile.name }
|
||||
</div>;
|
||||
}
|
||||
return <div className="mx_GroupView_featuredThings_container">
|
||||
{ catHeader }
|
||||
{ roomNodes }
|
||||
{ addButton }
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const FeaturedRoom = createReactClass({
|
||||
displayName: 'FeaturedRoom',
|
||||
|
||||
props: {
|
||||
class FeaturedRoom extends React.Component {
|
||||
static propTypes = {
|
||||
summaryInfo: RoomSummaryType.isRequired,
|
||||
editing: PropTypes.bool.isRequired,
|
||||
groupId: PropTypes.string.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onClick: function(e) {
|
||||
onClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -176,9 +174,9 @@ const FeaturedRoom = createReactClass({
|
||||
room_alias: this.props.summaryInfo.profile.canonical_alias,
|
||||
room_id: this.props.summaryInfo.room_id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onDeleteClicked: function(e) {
|
||||
onDeleteClicked = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
GroupStore.removeRoomFromGroupSummary(
|
||||
@@ -193,17 +191,18 @@ const FeaturedRoom = createReactClass({
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to remove room from group summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove the room from the summary of %(groupId)s",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
|
||||
});
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove the room from the summary of %(groupId)s",
|
||||
{ groupId: this.props.groupId },
|
||||
),
|
||||
description: _t("The room '%(roomName)s' could not be removed from the summary.", { roomName }),
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
|
||||
|
||||
const roomName = this.props.summaryInfo.profile.name ||
|
||||
@@ -243,13 +242,11 @@ const FeaturedRoom = createReactClass({
|
||||
<div className="mx_GroupView_featuredThing_name">{ roomNameNode }</div>
|
||||
{ deleteButton }
|
||||
</AccessibleButton>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const RoleUserList = createReactClass({
|
||||
displayName: 'RoleUserList',
|
||||
|
||||
props: {
|
||||
class RoleUserList extends React.Component {
|
||||
static propTypes = {
|
||||
users: PropTypes.arrayOf(UserSummaryType).isRequired,
|
||||
role: PropTypes.shape({
|
||||
profile: PropTypes.shape({
|
||||
@@ -260,9 +257,9 @@ const RoleUserList = createReactClass({
|
||||
|
||||
// Whether the list should be editable
|
||||
editing: PropTypes.bool.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onAddUsersClicked: function(ev) {
|
||||
onAddUsersClicked = (ev) => {
|
||||
ev.preventDefault();
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, {
|
||||
@@ -276,7 +273,7 @@ const RoleUserList = createReactClass({
|
||||
onFinished: (success, addrs) => {
|
||||
if (!success) return;
|
||||
const errorList = [];
|
||||
allSettled(addrs.map((addr) => {
|
||||
Promise.allSettled(addrs.map((addr) => {
|
||||
return GroupStore
|
||||
.addUserToGroupSummary(addr.address)
|
||||
.catch(() => { errorList.push(addr.address); });
|
||||
@@ -288,27 +285,27 @@ const RoleUserList = createReactClass({
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to add the following users to the community summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following users to the summary of %(groupId)s:",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
});
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following users to the summary of %(groupId)s:",
|
||||
{ groupId: this.props.groupId },
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
render() {
|
||||
const addButton = this.props.editing ?
|
||||
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}>
|
||||
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
|
||||
<div className="mx_GroupView_featuredThings_addButton_label">
|
||||
{ _t('Add a User') }
|
||||
</div>
|
||||
</AccessibleButton>) : <div />;
|
||||
<img src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
|
||||
<div className="mx_GroupView_featuredThings_addButton_label">
|
||||
{ _t('Add a User') }
|
||||
</div>
|
||||
</AccessibleButton>) : <div />;
|
||||
const userNodes = this.props.users.map((u) => {
|
||||
return <FeaturedUser
|
||||
key={u.user_id}
|
||||
@@ -325,19 +322,17 @@ const RoleUserList = createReactClass({
|
||||
{ userNodes }
|
||||
{ addButton }
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const FeaturedUser = createReactClass({
|
||||
displayName: 'FeaturedUser',
|
||||
|
||||
props: {
|
||||
class FeaturedUser extends React.Component {
|
||||
static propTypes = {
|
||||
summaryInfo: UserSummaryType.isRequired,
|
||||
editing: PropTypes.bool.isRequired,
|
||||
groupId: PropTypes.string.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onClick: function(e) {
|
||||
onClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -345,9 +340,9 @@ const FeaturedUser = createReactClass({
|
||||
action: 'view_start_chat_or_reuse',
|
||||
user_id: this.props.summaryInfo.user_id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onDeleteClicked: function(e) {
|
||||
onDeleteClicked = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
GroupStore.removeUserFromGroupSummary(
|
||||
@@ -359,25 +354,29 @@ const FeaturedUser = createReactClass({
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to remove user from community summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove a user from the summary of %(groupId)s",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
|
||||
});
|
||||
'',
|
||||
ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove a user from the summary of %(groupId)s",
|
||||
{ groupId: this.props.groupId },
|
||||
),
|
||||
description: _t(
|
||||
"The user '%(displayName)s' could not be removed from the summary.",
|
||||
{ displayName },
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id;
|
||||
|
||||
const permalink = makeUserPermalink(this.props.summaryInfo.user_id);
|
||||
const userNameNode = <a href={permalink} onClick={this.onClick}>{ name }</a>;
|
||||
const httpUrl = MatrixClientPeg.get()
|
||||
.mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64);
|
||||
const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64);
|
||||
|
||||
const deleteButton = this.props.editing ?
|
||||
<img
|
||||
@@ -394,41 +393,38 @@ const FeaturedUser = createReactClass({
|
||||
<div className="mx_GroupView_featuredThing_name">{ userNameNode }</div>
|
||||
{ deleteButton }
|
||||
</AccessibleButton>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_JOINPOLICY_OPEN = "open";
|
||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'GroupView',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("structures.GroupView")
|
||||
export default class GroupView extends React.Component {
|
||||
static propTypes = {
|
||||
groupId: PropTypes.string.isRequired,
|
||||
// Whether this is the first time the group admin is viewing the group
|
||||
groupIsNew: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
summary: null,
|
||||
isGroupPublicised: null,
|
||||
isUserPrivileged: null,
|
||||
groupRooms: null,
|
||||
groupRoomsLoading: null,
|
||||
error: null,
|
||||
editing: false,
|
||||
saving: false,
|
||||
uploadingAvatar: false,
|
||||
avatarChanged: false,
|
||||
membershipBusy: false,
|
||||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
summary: null,
|
||||
isGroupPublicised: null,
|
||||
isUserPrivileged: null,
|
||||
groupRooms: null,
|
||||
groupRoomsLoading: null,
|
||||
error: null,
|
||||
editing: false,
|
||||
saving: false,
|
||||
uploadingAvatar: false,
|
||||
avatarChanged: false,
|
||||
membershipBusy: false,
|
||||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
this._matrixClient = MatrixClientPeg.get();
|
||||
this._matrixClient.on("Group.myMembership", this._onGroupMyMembership);
|
||||
@@ -437,9 +433,9 @@ export default createReactClass({
|
||||
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
dis.unregister(this._dispatcherRef);
|
||||
@@ -448,10 +444,11 @@ export default createReactClass({
|
||||
if (this._rightPanelStoreToken) {
|
||||
this._rightPanelStoreToken.remove();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(newProps) {
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (this.props.groupId !== newProps.groupId) {
|
||||
this.setState({
|
||||
summary: null,
|
||||
@@ -460,24 +457,24 @@ export default createReactClass({
|
||||
this._initGroupStore(newProps.groupId);
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_onRightPanelStoreUpdate: function() {
|
||||
_onRightPanelStoreUpdate = () => {
|
||||
this.setState({
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onGroupMyMembership: function(group) {
|
||||
_onGroupMyMembership = (group) => {
|
||||
if (this._unmounted || group.groupId !== this.props.groupId) return;
|
||||
if (group.myMembership === 'leave') {
|
||||
// Leave settings - the user might have clicked the "Leave" button
|
||||
this._closeSettings();
|
||||
}
|
||||
this.setState({membershipBusy: false});
|
||||
},
|
||||
this.setState({ membershipBusy: false });
|
||||
};
|
||||
|
||||
_initGroupStore: function(groupId, firstInit) {
|
||||
_initGroupStore(groupId, firstInit) {
|
||||
const group = this._matrixClient.getGroup(groupId);
|
||||
if (group && group.inviter && group.inviter.userId) {
|
||||
this._fetchInviterProfile(group.inviter.userId);
|
||||
@@ -495,7 +492,7 @@ export default createReactClass({
|
||||
group_id: groupId,
|
||||
},
|
||||
});
|
||||
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}});
|
||||
dis.dispatch({ action: 'require_registration', screen_after: { screen: `group/${groupId}` } });
|
||||
willDoOnboarding = true;
|
||||
}
|
||||
if (stateKey === GroupStore.STATE_KEY.Summary) {
|
||||
@@ -506,9 +503,9 @@ export default createReactClass({
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onGroupStoreUpdated(firstInit) {
|
||||
onGroupStoreUpdated = (firstInit) => {
|
||||
if (this._unmounted) return;
|
||||
const summary = GroupStore.getSummary(this.props.groupId);
|
||||
if (summary.profile) {
|
||||
@@ -533,7 +530,7 @@ export default createReactClass({
|
||||
if (this.props.groupIsNew && firstInit) {
|
||||
this._onEditClick();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_fetchInviterProfile(userId) {
|
||||
this.setState({
|
||||
@@ -555,9 +552,9 @@ export default createReactClass({
|
||||
inviterProfileBusy: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onEditClick: function() {
|
||||
_onEditClick = () => {
|
||||
this.setState({
|
||||
editing: true,
|
||||
profileForm: Object.assign({}, this.state.summary.profile),
|
||||
@@ -568,20 +565,20 @@ export default createReactClass({
|
||||
GROUP_JOINPOLICY_INVITE,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onShareClick: function() {
|
||||
_onShareClick = () => {
|
||||
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
|
||||
Modal.createTrackedDialog('share community dialog', '', ShareDialog, {
|
||||
target: this._matrixClient.getGroup(this.props.groupId) || new Group(this.props.groupId),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onCancelClick: function() {
|
||||
_onCancelClick = () => {
|
||||
this._closeSettings();
|
||||
},
|
||||
};
|
||||
|
||||
_onAction(payload) {
|
||||
_onAction = (payload) => {
|
||||
switch (payload.action) {
|
||||
// NOTE: close_settings is an app-wide dispatch; as it is dispatched from MatrixChat
|
||||
case 'close_settings':
|
||||
@@ -593,38 +590,38 @@ export default createReactClass({
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_closeSettings() {
|
||||
dis.dispatch({action: 'close_settings'});
|
||||
},
|
||||
_closeSettings = () => {
|
||||
dis.dispatch({ action: 'close_settings' });
|
||||
};
|
||||
|
||||
_onNameChange: function(value) {
|
||||
_onNameChange = (value) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { name: value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onShortDescChange: function(value) {
|
||||
_onShortDescChange = (value) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { short_description: value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onLongDescChange: function(e) {
|
||||
_onLongDescChange = (e) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { long_description: e.target.value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onAvatarSelected: function(ev) {
|
||||
_onAvatarSelected = ev => {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
this.setState({uploadingAvatar: true});
|
||||
this.setState({ uploadingAvatar: true });
|
||||
this._matrixClient.uploadContent(file).then((url) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url });
|
||||
this.setState({
|
||||
@@ -632,11 +629,11 @@ export default createReactClass({
|
||||
profileForm: newProfileForm,
|
||||
|
||||
// Indicate that FlairStore needs to be poked to show this change
|
||||
// in TagTile (TagPanel), Flair and GroupTile (MyGroups).
|
||||
// in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups).
|
||||
avatarChanged: true,
|
||||
});
|
||||
}).catch((e) => {
|
||||
this.setState({uploadingAvatar: false});
|
||||
this.setState({ uploadingAvatar: false });
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to upload avatar image", e);
|
||||
Modal.createTrackedDialog('Failed to upload image', '', ErrorDialog, {
|
||||
@@ -644,16 +641,16 @@ export default createReactClass({
|
||||
description: _t('Failed to upload image'),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onJoinableChange: function(ev) {
|
||||
_onJoinableChange = ev => {
|
||||
this.setState({
|
||||
joinableForm: { policyType: ev.target.value },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onSaveClick: function() {
|
||||
this.setState({saving: true});
|
||||
_onSaveClick = () => {
|
||||
this.setState({ saving: true });
|
||||
const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve();
|
||||
savePromise.then((result) => {
|
||||
this.setState({
|
||||
@@ -661,7 +658,6 @@ export default createReactClass({
|
||||
editing: false,
|
||||
summary: null,
|
||||
});
|
||||
dis.dispatch({action: 'panel_disable'});
|
||||
this._initGroupStore(this.props.groupId);
|
||||
|
||||
if (this.state.avatarChanged) {
|
||||
@@ -683,17 +679,17 @@ export default createReactClass({
|
||||
avatarChanged: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_saveGroup: async function() {
|
||||
async _saveGroup() {
|
||||
await this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm);
|
||||
await this._matrixClient.setGroupJoinPolicy(this.props.groupId, {
|
||||
type: this.state.joinableForm.policyType,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onAcceptInviteClick: async function() {
|
||||
this.setState({membershipBusy: true});
|
||||
_onAcceptInviteClick = async () => {
|
||||
this.setState({ membershipBusy: true });
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
@@ -702,17 +698,17 @@ export default createReactClass({
|
||||
GroupStore.acceptGroupInvite(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
this.setState({ membershipBusy: false });
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error accepting invite', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Unable to accept invite"),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onRejectInviteClick: async function() {
|
||||
this.setState({membershipBusy: true});
|
||||
_onRejectInviteClick = async () => {
|
||||
this.setState({ membershipBusy: true });
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
@@ -721,22 +717,22 @@ export default createReactClass({
|
||||
GroupStore.leaveGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
this.setState({ membershipBusy: false });
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error rejecting invite', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Unable to reject invite"),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onJoinClick: async function() {
|
||||
_onJoinClick = async () => {
|
||||
if (this._matrixClient.isGuest()) {
|
||||
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
|
||||
dis.dispatch({ action: 'require_registration', screen_after: { screen: `group/${this.props.groupId}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({membershipBusy: true});
|
||||
this.setState({ membershipBusy: true });
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
@@ -745,16 +741,16 @@ export default createReactClass({
|
||||
GroupStore.joinGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
this.setState({ membershipBusy: false });
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error joining room', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Unable to join community"),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_leaveGroupWarnings: function() {
|
||||
_leaveGroupWarnings() {
|
||||
const warnings = [];
|
||||
|
||||
if (this.state.isUserPrivileged) {
|
||||
@@ -768,10 +764,9 @@ export default createReactClass({
|
||||
}
|
||||
|
||||
return warnings;
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_onLeaveClick: function() {
|
||||
_onLeaveClick = () => {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const warnings = this._leaveGroupWarnings();
|
||||
|
||||
@@ -779,8 +774,8 @@ export default createReactClass({
|
||||
title: _t("Leave Community"),
|
||||
description: (
|
||||
<span>
|
||||
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
|
||||
{ warnings }
|
||||
{ _t("Leave %(groupName)s?", { groupName: this.props.groupId }) }
|
||||
{ warnings }
|
||||
</span>
|
||||
),
|
||||
button: _t("Leave"),
|
||||
@@ -788,7 +783,7 @@ export default createReactClass({
|
||||
onFinished: async (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
|
||||
this.setState({membershipBusy: true});
|
||||
this.setState({ membershipBusy: true });
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
@@ -797,7 +792,7 @@ export default createReactClass({
|
||||
GroupStore.leaveGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
this.setState({ membershipBusy: false });
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error leaving community', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
@@ -806,13 +801,13 @@ export default createReactClass({
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onAddRoomsClick: function() {
|
||||
_onAddRoomsClick = () => {
|
||||
showGroupAddRoomDialog(this.props.groupId);
|
||||
},
|
||||
};
|
||||
|
||||
_getGroupSection: function() {
|
||||
_getGroupSection() {
|
||||
const groupSettingsSectionClasses = classnames({
|
||||
"mx_GroupView_group": this.state.editing,
|
||||
"mx_GroupView_group_disabled": this.state.editing && !this.state.isUserPrivileged,
|
||||
@@ -856,12 +851,11 @@ export default createReactClass({
|
||||
{ this._getLongDescriptionNode() }
|
||||
{ this._getRoomsNode() }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getRoomsNode: function() {
|
||||
_getRoomsNode() {
|
||||
const RoomDetailList = sdk.getComponent('rooms.RoomDetailList');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
const TooltipButton = sdk.getComponent('elements.TooltipButton');
|
||||
|
||||
@@ -877,16 +871,13 @@ export default createReactClass({
|
||||
onClick={this._onAddRoomsClick}
|
||||
>
|
||||
<div className="mx_GroupView_rooms_header_addRow_button">
|
||||
<TintableSvg src={require("../../../res/img/icons-room-add.svg")} width="24" height="24" />
|
||||
<img src={require("../../../res/img/icons-room-add.svg")} width="24" height="24" />
|
||||
</div>
|
||||
<div className="mx_GroupView_rooms_header_addRow_label">
|
||||
{ _t('Add rooms to this community') }
|
||||
</div>
|
||||
</AccessibleButton>) : <div />;
|
||||
const roomDetailListClassName = classnames({
|
||||
"mx_fadable": true,
|
||||
"mx_fadable_faded": this.state.editing,
|
||||
});
|
||||
|
||||
return <div className="mx_GroupView_rooms">
|
||||
<div className="mx_GroupView_rooms_header">
|
||||
<h3>
|
||||
@@ -897,14 +888,12 @@ export default createReactClass({
|
||||
</div>
|
||||
{ this.state.groupRoomsLoading ?
|
||||
<Spinner /> :
|
||||
<RoomDetailList
|
||||
rooms={this.state.groupRooms}
|
||||
className={roomDetailListClassName} />
|
||||
<RoomDetailList rooms={this.state.groupRooms} />
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getFeaturedRoomsNode: function() {
|
||||
_getFeaturedRoomsNode() {
|
||||
const summary = this.state.summary;
|
||||
|
||||
const defaultCategoryRooms = [];
|
||||
@@ -943,9 +932,9 @@ export default createReactClass({
|
||||
{ defaultCategoryNode }
|
||||
{ categoryRoomNodes }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getFeaturedUsersNode: function() {
|
||||
_getFeaturedUsersNode() {
|
||||
const summary = this.state.summary;
|
||||
|
||||
const noRoleUsers = [];
|
||||
@@ -984,9 +973,9 @@ export default createReactClass({
|
||||
{ noRoleNode }
|
||||
{ roleUserNodes }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getMembershipSection: function() {
|
||||
_getMembershipSection() {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
|
||||
@@ -998,14 +987,14 @@ export default createReactClass({
|
||||
<Spinner />
|
||||
</div>;
|
||||
}
|
||||
const httpInviterAvatar = this.state.inviterProfile ?
|
||||
this._matrixClient.mxcUrlToHttp(
|
||||
this.state.inviterProfile.avatarUrl, 36, 36,
|
||||
) : null;
|
||||
const httpInviterAvatar = this.state.inviterProfile && this.state.inviterProfile.avatarUrl
|
||||
? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36)
|
||||
: null;
|
||||
|
||||
let inviterName = group.inviter.userId;
|
||||
const inviter = group.inviter || {};
|
||||
let inviterName = inviter.userId;
|
||||
if (this.state.inviterProfile) {
|
||||
inviterName = this.state.inviterProfile.displayName || group.inviter.userId;
|
||||
inviterName = this.state.inviterProfile.displayName || inviter.userId;
|
||||
}
|
||||
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_invited">
|
||||
<div className="mx_GroupView_membershipSubSection">
|
||||
@@ -1016,7 +1005,7 @@ export default createReactClass({
|
||||
height={36}
|
||||
/>
|
||||
{ _t("%(inviter)s has invited you to join this community", {
|
||||
inviter: inviterName,
|
||||
inviter: inviterName || _t("Someone"),
|
||||
}) }
|
||||
</div>
|
||||
<div className="mx_GroupView_membership_buttonContainer">
|
||||
@@ -1072,10 +1061,11 @@ export default createReactClass({
|
||||
return null;
|
||||
}
|
||||
|
||||
const membershipButtonClasses = classnames([
|
||||
'mx_RoomHeader_textButton',
|
||||
'mx_GroupView_textButton',
|
||||
],
|
||||
const membershipButtonClasses = classnames(
|
||||
[
|
||||
'mx_RoomHeader_textButton',
|
||||
'mx_GroupView_textButton',
|
||||
],
|
||||
membershipButtonExtraClasses,
|
||||
);
|
||||
|
||||
@@ -1099,9 +1089,9 @@ export default createReactClass({
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getJoinableNode: function() {
|
||||
_getJoinableNode() {
|
||||
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
|
||||
return this.state.editing ? <div>
|
||||
<h3>
|
||||
@@ -1135,9 +1125,9 @@ export default createReactClass({
|
||||
</label>
|
||||
</div>
|
||||
</div> : null;
|
||||
},
|
||||
}
|
||||
|
||||
_getLongDescriptionNode: function() {
|
||||
_getLongDescriptionNode() {
|
||||
const summary = this.state.summary;
|
||||
let description = null;
|
||||
if (summary.profile && summary.profile.long_description) {
|
||||
@@ -1174,9 +1164,9 @@ export default createReactClass({
|
||||
<div className="mx_GroupView_groupDesc">
|
||||
{ description }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
|
||||
@@ -1334,7 +1324,7 @@ export default createReactClass({
|
||||
</div>
|
||||
<GroupHeaderButtons />
|
||||
</div>
|
||||
<MainSplit panel={rightPanel}>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<AutoHideScrollbar className="mx_GroupView_body">
|
||||
{ this._getMembershipSection() }
|
||||
{ this._getGroupSection() }
|
||||
@@ -1346,7 +1336,7 @@ export default createReactClass({
|
||||
if (this.state.error.httpStatus === 404) {
|
||||
return (
|
||||
<div className="mx_GroupView_error">
|
||||
{ _t('Community %(groupId)s not found', {groupId: this.props.groupId}) }
|
||||
{ _t('Community %(groupId)s not found', { groupId: this.props.groupId }) }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -1356,7 +1346,7 @@ export default createReactClass({
|
||||
}
|
||||
return (
|
||||
<div className="mx_GroupView_error">
|
||||
{ _t('Failed to load %(groupId)s', {groupId: this.props.groupId }) }
|
||||
{ _t('Failed to load %(groupId)s', { groupId: this.props.groupId }) }
|
||||
{ extraText }
|
||||
</div>
|
||||
);
|
||||
@@ -1365,5 +1355,5 @@ export default createReactClass({
|
||||
console.error("Invalid state for GroupView");
|
||||
return <div />;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,39 +15,112 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
import { _t } from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { useEventEmitter } from "../../hooks/useEventEmitter";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import MiniAvatarUploader, { AVATAR_SIZE } from "../views/elements/MiniAvatarUploader";
|
||||
import Analytics from "../../Analytics";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
|
||||
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
|
||||
const onClickExplore = () => dis.dispatch({action: 'view_room_directory'});
|
||||
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
|
||||
const onClickSendDm = () => {
|
||||
Analytics.trackEvent('home_page', 'button', 'dm');
|
||||
CountlyAnalytics.instance.track("home_page_button", { button: "dm" });
|
||||
dis.dispatch({ action: 'view_create_chat' });
|
||||
};
|
||||
|
||||
const HomePage = () => {
|
||||
const onClickExplore = () => {
|
||||
Analytics.trackEvent('home_page', 'button', 'room_directory');
|
||||
CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" });
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
const onClickNewRoom = () => {
|
||||
Analytics.trackEvent('home_page', 'button', 'create_room');
|
||||
CountlyAnalytics.instance.track("home_page_button", { button: "create_room" });
|
||||
dis.dispatch({ action: 'view_create_room' });
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
justRegistered?: boolean;
|
||||
}
|
||||
|
||||
const getOwnProfile = (userId: string) => ({
|
||||
displayName: OwnProfileStore.instance.displayName || userId,
|
||||
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE),
|
||||
});
|
||||
|
||||
const UserWelcomeTop = () => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userId = cli.getUserId();
|
||||
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
|
||||
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
|
||||
setOwnProfile(getOwnProfile(userId));
|
||||
});
|
||||
|
||||
return <div>
|
||||
<MiniAvatarUploader
|
||||
hasAvatar={!!ownProfile.avatarUrl}
|
||||
hasAvatarLabel={_t("Great, that'll help people know it's you")}
|
||||
noAvatarLabel={_t("Add a photo so people know it's you.")}
|
||||
setAvatarUrl={url => cli.setAvatarUrl(url)}
|
||||
>
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={ownProfile.displayName}
|
||||
url={ownProfile.avatarUrl}
|
||||
width={AVATAR_SIZE}
|
||||
height={AVATAR_SIZE}
|
||||
resizeMethod="crop"
|
||||
/>
|
||||
</MiniAvatarUploader>
|
||||
|
||||
<h1>{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }</h1>
|
||||
<h4>{ _t("Now, let's help you get started") }</h4>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
|
||||
const config = SdkConfig.get();
|
||||
const pageUrl = getHomePageUrl(config);
|
||||
|
||||
if (pageUrl) {
|
||||
// FIXME: Using an import will result in wrench-element-tests failures
|
||||
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
|
||||
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
|
||||
}
|
||||
|
||||
const brandingConfig = config.branding;
|
||||
let logoUrl = "themes/riot/img/logos/riot-logo.svg";
|
||||
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
|
||||
logoUrl = brandingConfig.authHeaderLogoUrl;
|
||||
let introSection;
|
||||
if (justRegistered) {
|
||||
introSection = <UserWelcomeTop />;
|
||||
} else {
|
||||
const brandingConfig = config.branding;
|
||||
let logoUrl = "themes/element/img/logos/element-logo.svg";
|
||||
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
|
||||
logoUrl = brandingConfig.authHeaderLogoUrl;
|
||||
}
|
||||
|
||||
introSection = <React.Fragment>
|
||||
<img src={logoUrl} alt={config.brand} />
|
||||
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand }) }</h1>
|
||||
<h4>{ _t("Liberate your communication") }</h4>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
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" />
|
||||
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Riot" }) }</h1>
|
||||
<h4>{ _t("Liberate your communication") }</h4>
|
||||
{ introSection }
|
||||
<div className="mx_HomePage_default_buttons">
|
||||
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
|
||||
{ _t("Send a Direct Message") }
|
||||
|
||||
61
src/components/structures/HostSignupAction.tsx
Normal file
61
src/components/structures/HostSignupAction.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { HostSignupStore } from "../../stores/HostSignupStore";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {}
|
||||
|
||||
@replaceableComponent("structures.HostSignupAction")
|
||||
export default class HostSignupAction extends React.PureComponent<IProps, IState> {
|
||||
private openDialog = async () => {
|
||||
this.props.onClick?.();
|
||||
await HostSignupStore.instance.setHostSignupActive(true);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const hostSignupConfig = SdkConfig.get().hostSignup;
|
||||
if (!hostSignupConfig?.brand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconHosting"
|
||||
label={_t(
|
||||
"Upgrade to %(hostSignupBrand)s",
|
||||
{
|
||||
hostSignupBrand: hostSignupConfig.brand,
|
||||
},
|
||||
)}
|
||||
onClick={this.openDialog}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ limitations under the License.
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("structures.IndicatorScrollbar")
|
||||
export default class IndicatorScrollbar extends React.Component {
|
||||
static propTypes = {
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
@@ -57,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
_collectScroller(scroller) {
|
||||
if (scroller && !this._scrollElement) {
|
||||
this._scrollElement = scroller;
|
||||
this._scrollElement.addEventListener("scroll", this.checkOverflow);
|
||||
// Using the passive option to not block the main thread
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
||||
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
|
||||
this.checkOverflow();
|
||||
}
|
||||
}
|
||||
@@ -66,7 +70,6 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
this._autoHideScrollbar = autoHideScrollbar;
|
||||
}
|
||||
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
|
||||
const curLen = this.props.children && this.props.children.length || 0;
|
||||
@@ -158,7 +161,7 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
}
|
||||
|
||||
// don't mess with the horizontal scroll for trackpad users
|
||||
// See https://github.com/vector-im/riot-web/issues/10005
|
||||
// See https://github.com/vector-im/element-web/issues/10005
|
||||
if (this._likelyTrackpadUser) {
|
||||
return;
|
||||
}
|
||||
@@ -181,21 +184,24 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
|
||||
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
|
||||
const leftOverflowIndicator = this.props.trackHorizontalOverflow
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
|
||||
|
||||
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
|
||||
const rightIndicatorStyle = { right: this.state.rightIndicatorOffset };
|
||||
const leftOverflowIndicator = trackHorizontalOverflow
|
||||
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
|
||||
const rightOverflowIndicator = this.props.trackHorizontalOverflow
|
||||
const rightOverflowIndicator = trackHorizontalOverflow
|
||||
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
|
||||
|
||||
return (<AutoHideScrollbar
|
||||
ref={this._collectScrollerComponent}
|
||||
wrappedRef={this._collectScroller}
|
||||
onWheel={this.onMouseWheel}
|
||||
{... this.props}
|
||||
{...otherProps}
|
||||
>
|
||||
{ leftOverflowIndicator }
|
||||
{ this.props.children }
|
||||
{ children }
|
||||
{ rightOverflowIndicator }
|
||||
</AutoHideScrollbar>);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {InteractiveAuth} from "matrix-js-sdk";
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'InteractiveAuth',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("structures.InteractiveAuthComponent")
|
||||
export default class InteractiveAuthComponent extends React.Component {
|
||||
static propTypes = {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
|
||||
@@ -55,7 +54,7 @@ export default createReactClass({
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
// sessions with the identity server.
|
||||
onAuthFinished: PropTypes.func.isRequired,
|
||||
|
||||
// Inputs provided by the user to the auth process
|
||||
@@ -86,20 +85,19 @@ export default createReactClass({
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText: PropTypes.string,
|
||||
continueKind: PropTypes.string,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this._authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
@@ -114,6 +112,18 @@ export default createReactClass({
|
||||
requestEmailToken: this._requestEmailToken,
|
||||
});
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
this._intervalId = setInterval(() => {
|
||||
this._authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this._stageComponent = createRef();
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
this._authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this._authLogic.getEmailSid(),
|
||||
@@ -132,26 +142,17 @@ export default createReactClass({
|
||||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
this._intervalId = setInterval(() => {
|
||||
this._authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this._stageComponent = createRef();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
|
||||
if (this._intervalId !== null) {
|
||||
clearInterval(this._intervalId);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_requestEmailToken: async function(...args) {
|
||||
_requestEmailToken = async (...args) => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
@@ -162,15 +163,15 @@ export default createReactClass({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
tryContinue: function() {
|
||||
tryContinue = () => {
|
||||
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
|
||||
this._stageComponent.current.tryContinue();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_authStateUpdated: function(stageType, stageState) {
|
||||
_authStateUpdated = (stageType, stageState) => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
@@ -178,18 +179,25 @@ export default createReactClass({
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage != stageType) this._setFocus();
|
||||
if (oldStage !== stageType) {
|
||||
this._setFocus();
|
||||
} else if (
|
||||
!stageState.error && this._stageComponent.current &&
|
||||
this._stageComponent.current.attemptFailed
|
||||
) {
|
||||
this._stageComponent.current.attemptFailed();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_requestCallback: function(auth) {
|
||||
_requestCallback = (auth) => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
},
|
||||
};
|
||||
|
||||
_onBusyChanged: function(busy) {
|
||||
_onBusyChanged = (busy) => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
@@ -203,30 +211,30 @@ export default createReactClass({
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `_authStateUpdated`.
|
||||
// See also https://github.com/vector-im/riot-web/issues/12546
|
||||
},
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
};
|
||||
|
||||
_setFocus: function() {
|
||||
_setFocus() {
|
||||
if (this._stageComponent.current && this._stageComponent.current.focus) {
|
||||
this._stageComponent.current.focus();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_submitAuthDict: function(authData) {
|
||||
_submitAuthDict = authData => {
|
||||
this._authLogic.submitAuthDict(authData);
|
||||
},
|
||||
};
|
||||
|
||||
_onPhaseChange: function(newPhase) {
|
||||
_onPhaseChange = newPhase => {
|
||||
if (this.props.onStagePhaseChange) {
|
||||
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onStageCancel: function() {
|
||||
_onStageCancel = () => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
},
|
||||
};
|
||||
|
||||
_renderCurrentStage: function() {
|
||||
_renderCurrentStage() {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
@@ -260,16 +268,17 @@ export default createReactClass({
|
||||
onCancel={this._onStageCancel}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
_onAuthStageFailed: function(e) {
|
||||
_onAuthStageFailed = e => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
},
|
||||
_setEmailSid: function(sid) {
|
||||
this._authLogic.setEmailSid(sid);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
_setEmailSid = sid => {
|
||||
this._authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
@@ -287,5 +296,5 @@ export default createReactClass({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,302 +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';
|
||||
import * as VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
import Analytics from "../../Analytics";
|
||||
|
||||
|
||||
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.dispatch({action: 'focus_composer'});
|
||||
}
|
||||
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.dispatch({action: 'view_room_directory'})}>{_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} />);
|
||||
}
|
||||
|
||||
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
|
||||
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} />
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default LeftPanel;
|
||||
501
src/components/structures/LeftPanel.tsx
Normal file
501
src/components/structures/LeftPanel.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import GroupFilterPanel from "./GroupFilterPanel";
|
||||
import CustomRoomTagPanel from "./CustomRoomTagPanel";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList from "../views/rooms/RoomList";
|
||||
import CallHandler from "../../CallHandler";
|
||||
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 IndicatorScrollbar from "../structures/IndicatorScrollbar";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||
import LeftPanelWidget from "./LeftPanelWidget";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
showBreadcrumbs: boolean;
|
||||
showGroupFilterPanel: boolean;
|
||||
activeSpace?: Room;
|
||||
}
|
||||
|
||||
// List of CSS classes which should be included in keyboard navigation within the room list
|
||||
const cssClasses = [
|
||||
"mx_RoomSearch_input",
|
||||
"mx_RoomSearch_minimizedHandle", // minimized <RoomSearch />
|
||||
"mx_RoomSublist_headerText",
|
||||
"mx_RoomTile",
|
||||
"mx_RoomSublist_showNButton",
|
||||
];
|
||||
|
||||
@replaceableComponent("structures.LeftPanel")
|
||||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private groupFilterPanelWatcherRef: string;
|
||||
private bgImageWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
activeSpace: SpaceStore.instance.activeSpace,
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
this.bgImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
|
||||
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") });
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
|
||||
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
|
||||
// Using the passive option to not block the main thread
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
||||
this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
UIStore.instance.stopTrackingElementDimensions("ListContainer");
|
||||
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
|
||||
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
|
||||
if (prevState.activeSpace !== this.state.activeSpace) {
|
||||
this.refreshStickyHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
private updateActiveSpace = (activeSpace: Room) => {
|
||||
this.setState({ activeSpace });
|
||||
};
|
||||
|
||||
private onDialPad = () => {
|
||||
dis.fire(Action.OpenDialPad);
|
||||
};
|
||||
|
||||
private onExplore = () => {
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
private refreshStickyHeaders = () => {
|
||||
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
|
||||
this.handleStickyHeaders(this.listContainerRef.current);
|
||||
};
|
||||
|
||||
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 onBackgroundImageUpdate = () => {
|
||||
// Note: we do this in the LeftPanel as it uses this variable most prominently.
|
||||
const avatarSize = 32; // arbitrary
|
||||
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
|
||||
if (settingBgMxc) {
|
||||
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
|
||||
}
|
||||
|
||||
const avatarUrlProp = `url(${avatarUrl})`;
|
||||
if (!avatarUrl) {
|
||||
document.body.style.removeProperty("--avatar-url");
|
||||
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
document.body.style.setProperty("--avatar-url", avatarUrlProp);
|
||||
}
|
||||
};
|
||||
|
||||
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:not(.mx_RoomSublist_hidden)");
|
||||
|
||||
// We track which styles we want on a target before making the changes to avoid
|
||||
// excessive layout updates.
|
||||
const targetStyles = new Map<HTMLDivElement, {
|
||||
stickyTop?: boolean;
|
||||
stickyBottom?: boolean;
|
||||
makeInvisible?: boolean;
|
||||
}>();
|
||||
|
||||
let lastTopHeader;
|
||||
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");
|
||||
}
|
||||
|
||||
const offset = UIStore.instance.windowHeight -
|
||||
(list.parentElement.offsetTop + list.parentElement.offsetHeight);
|
||||
const newBottom = `${offset}px`;
|
||||
if (header.style.bottom !== newBottom) {
|
||||
header.style.bottom = newBottom;
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
if (header.style.bottom) {
|
||||
header.style.removeProperty('bottom');
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyTop || style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_sticky");
|
||||
}
|
||||
|
||||
const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
|
||||
if (listDimensions) {
|
||||
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
|
||||
const headerStickyWidth = listDimensions.width - headerRightMargin;
|
||||
const newWidth = `${headerStickyWidth}px`;
|
||||
if (header.style.width !== newWidth) {
|
||||
header.style.width = newWidth;
|
||||
}
|
||||
}
|
||||
} else if (!style.stickyTop && !style.stickyBottom) {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
|
||||
}
|
||||
|
||||
if (header.style.width) {
|
||||
header.style.removeProperty('width');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add appropriate sticky classes to wrapper so it has
|
||||
// the necessary top/bottom padding to put the sticky header in
|
||||
const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper
|
||||
if (lastTopHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop");
|
||||
}
|
||||
if (firstBottomHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom");
|
||||
}
|
||||
}
|
||||
|
||||
private onScroll = (ev: Event) => {
|
||||
const list = ev.target as HTMLDivElement;
|
||||
this.handleStickyHeaders(list);
|
||||
};
|
||||
|
||||
private onFocus = (ev: React.FocusEvent) => {
|
||||
this.focusedElement = ev.target;
|
||||
};
|
||||
|
||||
private onBlur = () => {
|
||||
this.focusedElement = null;
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
const action = getKeyBindingsManager().getRoomListAction(ev);
|
||||
switch (action) {
|
||||
case RoomListAction.NextRoom:
|
||||
case RoomListAction.PrevRoom:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.onMoveFocus(action === RoomListAction.PrevRoom);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private selectRoom = () => {
|
||||
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)) || element.offsetParent === null));
|
||||
|
||||
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 renderSearchDialExplore(): React.ReactNode {
|
||||
let dialPadButton = null;
|
||||
|
||||
// If we have dialer support, show a button to bring up the dial pad
|
||||
// to start a new call
|
||||
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
|
||||
dialPadButton =
|
||||
<AccessibleTooltipButton
|
||||
className={classNames("mx_LeftPanel_dialPadButton", {})}
|
||||
onClick={this.onDialPad}
|
||||
title={_t("Open dial pad")}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx_LeftPanel_filterContainer"
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
<RoomSearch
|
||||
isMinimized={this.props.isMinimized}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onSelectRoom={this.selectRoom}
|
||||
/>
|
||||
|
||||
{dialPadButton}
|
||||
|
||||
<AccessibleTooltipButton
|
||||
className={classNames("mx_LeftPanel_exploreButton", {
|
||||
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
|
||||
})}
|
||||
onClick={this.onExplore}
|
||||
title={_t("Explore rooms")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let leftLeftPanel;
|
||||
if (this.state.showGroupFilterPanel) {
|
||||
leftLeftPanel = (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
isMinimized={this.props.isMinimized}
|
||||
activeSpace={this.state.activeSpace}
|
||||
onResize={this.refreshStickyHeaders}
|
||||
onListCollapse={this.refreshStickyHeaders}
|
||||
/>;
|
||||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel": true,
|
||||
"mx_LeftPanel_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
const roomListClasses = classNames(
|
||||
"mx_LeftPanel_actualRoomListContainer",
|
||||
"mx_AutoHideScrollbar",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses} ref={this.ref}>
|
||||
{leftLeftPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchDialExplore()}
|
||||
{this.renderBreadcrumbs()}
|
||||
<RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
|
||||
<div className="mx_LeftPanel_roomListWrapper">
|
||||
<div
|
||||
className={roomListClasses}
|
||||
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>
|
||||
{ !this.props.isMinimized && <LeftPanelWidget /> }
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
144
src/components/structures/LeftPanelWidget.tsx
Normal file
144
src/components/structures/LeftPanelWidget.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
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, { useContext, useMemo } from "react";
|
||||
import { Resizable } from "re-resizable";
|
||||
import classNames from "classnames";
|
||||
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
import { Key } from "../../Keyboard";
|
||||
import { useLocalStorageState } from "../../hooks/useLocalStorageState";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import WidgetUtils, { IWidgetEvent } from "../../utils/WidgetUtils";
|
||||
import { useAccountData } from "../../hooks/useAccountData";
|
||||
import AppTile from "../views/elements/AppTile";
|
||||
import { useSettingValue } from "../../hooks/useSettings";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
||||
const MIN_HEIGHT = 100;
|
||||
const MAX_HEIGHT = 500; // or 50% of the window height
|
||||
const INITIAL_HEIGHT = 280;
|
||||
|
||||
const LeftPanelWidget: React.FC = () => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
|
||||
const leftPanelWidgetId = useSettingValue("Widgets.leftPanel");
|
||||
const app = useMemo(() => {
|
||||
if (!mWidgetsEvent || !leftPanelWidgetId) return null;
|
||||
const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId);
|
||||
if (!widgetConfig) return null;
|
||||
|
||||
return WidgetUtils.makeAppConfig(
|
||||
widgetConfig.state_key,
|
||||
widgetConfig.content,
|
||||
widgetConfig.sender,
|
||||
null,
|
||||
widgetConfig.id);
|
||||
}, [mWidgetsEvent, leftPanelWidgetId]);
|
||||
|
||||
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
|
||||
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
|
||||
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
let content;
|
||||
if (expanded) {
|
||||
content = <Resizable
|
||||
size={{ height } as any}
|
||||
minHeight={MIN_HEIGHT}
|
||||
maxHeight={Math.min(UIStore.instance.windowHeight / 2, MAX_HEIGHT)}
|
||||
onResizeStop={(e, dir, ref, d) => {
|
||||
setHeight(height + d.height);
|
||||
}}
|
||||
handleWrapperClass="mx_LeftPanelWidget_resizerHandles"
|
||||
handleClasses={{ top: "mx_LeftPanelWidget_resizerHandle" }}
|
||||
className="mx_LeftPanelWidget_resizeBox"
|
||||
enable={{ top: true }}
|
||||
>
|
||||
<AppTile
|
||||
app={app}
|
||||
fullWidth
|
||||
show
|
||||
showMenubar={false}
|
||||
userWidget
|
||||
userId={cli.getUserId()}
|
||||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
/>
|
||||
</Resizable>;
|
||||
}
|
||||
|
||||
return <div className="mx_LeftPanelWidget">
|
||||
<div
|
||||
onFocus={onFocus}
|
||||
className="mx_LeftPanelWidget_headerContainer"
|
||||
onKeyDown={(ev: React.KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
setExpanded(false);
|
||||
break;
|
||||
case Key.ARROW_RIGHT: {
|
||||
ev.stopPropagation();
|
||||
setExpanded(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mx_LeftPanelWidget_stickable">
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className="mx_LeftPanelWidget_headerText"
|
||||
role="treeitem"
|
||||
aria-expanded={expanded}
|
||||
aria-level={1}
|
||||
onClick={() => {
|
||||
setExpanded(e => !e);
|
||||
}}
|
||||
>
|
||||
<span className={classNames({
|
||||
"mx_LeftPanelWidget_collapseBtn": true,
|
||||
"mx_LeftPanelWidget_collapseBtn_collapsed": !expanded,
|
||||
})} />
|
||||
<span>{ WidgetUtils.getWidgetName(app) }</span>
|
||||
</AccessibleButton>
|
||||
|
||||
{/* Code for the maximise button for once we have full screen widgets */}
|
||||
{/*<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={() => {
|
||||
}}
|
||||
className="mx_LeftPanelWidget_maximizeButton"
|
||||
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
|
||||
title={_t("Maximize")}
|
||||
/>*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ content }
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default LeftPanelWidget;
|
||||
@@ -1,666 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018 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 { MatrixClient } from 'matrix-js-sdk';
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import CallMediaHandler from '../../CallMediaHandler';
|
||||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import sessionStore from '../../stores/SessionStore';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore from "../../stores/RoomListStore";
|
||||
|
||||
import TagOrderActions from '../../actions/TagOrderActions';
|
||||
import RoomListActions from '../../actions/RoomListActions';
|
||||
import ResizeHandle from '../views/elements/ResizeHandle';
|
||||
import {Resizer, CollapseDistributor} from '../../resizer';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
|
||||
import HomePage from "./HomePage";
|
||||
// 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.
|
||||
// NB. this is just for server notices rather than pinned messages in general.
|
||||
const MAX_PINNED_NOTICES_PER_ROOM = 2;
|
||||
|
||||
function canElementReceiveInput(el) {
|
||||
return el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT" ||
|
||||
!!el.getAttribute("contenteditable");
|
||||
}
|
||||
|
||||
/**
|
||||
* This is what our MatrixChat shows when we are logged in. The precise view is
|
||||
* determined by the page_type property.
|
||||
*
|
||||
* Currently it's very tightly coupled with MatrixChat. We should try to do
|
||||
* something about that.
|
||||
*
|
||||
* Components mounted below us can access the matrix client via the react context.
|
||||
*/
|
||||
const LoggedInView = createReactClass({
|
||||
displayName: 'LoggedInView',
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
page_type: PropTypes.string.isRequired,
|
||||
onRoomCreated: PropTypes.func,
|
||||
|
||||
// Called with the credentials of a registered user (if they were a ROU that
|
||||
// transitioned to PWLU)
|
||||
onRegistered: PropTypes.func,
|
||||
|
||||
// Used by the RoomView to handle joining rooms
|
||||
viaServers: PropTypes.arrayOf(PropTypes.string),
|
||||
|
||||
// and lots and lots of other stuff.
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
// any currently active server notice events
|
||||
serverNoticeEvents: [],
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.resizer = this._createResizer();
|
||||
this.resizer.attach();
|
||||
this._loadResizerPreferences();
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
this._matrixClient = this.props.matrixClient;
|
||||
|
||||
CallMediaHandler.loadDevices();
|
||||
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
|
||||
this._sessionStore = sessionStore;
|
||||
this._sessionStoreToken = this._sessionStore.addListener(
|
||||
this._setStateFromSessionStore,
|
||||
);
|
||||
this._setStateFromSessionStore();
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = createRef();
|
||||
},
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// attempt to guess when a banner was opened or closed
|
||||
if (
|
||||
(prevProps.showCookieBar !== this.props.showCookieBar) ||
|
||||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
|
||||
(prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) ||
|
||||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
|
||||
) {
|
||||
this.props.resizeNotifier.notifyBannersChanged();
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
document.removeEventListener('keydown', this._onNativeKeyDown, false);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
if (this._sessionStoreToken) {
|
||||
this._sessionStoreToken.remove();
|
||||
}
|
||||
this.resizer.detach();
|
||||
},
|
||||
|
||||
// Child components assume that the client peg will not be null, so give them some
|
||||
// sort of assurance here by only allowing a re-render if the client is truthy.
|
||||
//
|
||||
// This is required because `LoggedInView` maintains its own state and if this state
|
||||
// updates after the client peg has been made null (during logout), then it will
|
||||
// attempt to re-render and the children will throw errors.
|
||||
shouldComponentUpdate: function() {
|
||||
return Boolean(MatrixClientPeg.get());
|
||||
},
|
||||
|
||||
canResetTimelineInRoom: function(roomId) {
|
||||
if (!this._roomView.current) {
|
||||
return true;
|
||||
}
|
||||
return this._roomView.current.canResetTimeline();
|
||||
},
|
||||
|
||||
_setStateFromSessionStore() {
|
||||
this.setState({
|
||||
userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()),
|
||||
});
|
||||
},
|
||||
|
||||
_createResizer() {
|
||||
const classNames = {
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
};
|
||||
const collapseConfig = {
|
||||
toggleSize: 260 - 50,
|
||||
onCollapsed: (collapsed) => {
|
||||
if (collapsed) {
|
||||
dis.dispatch({action: "hide_left_panel"}, true);
|
||||
window.localStorage.setItem("mx_lhs_size", '0');
|
||||
} else {
|
||||
dis.dispatch({action: "show_left_panel"}, true);
|
||||
}
|
||||
},
|
||||
onResized: (size) => {
|
||||
window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
this.props.resizeNotifier.notifyLeftHandleResized();
|
||||
},
|
||||
};
|
||||
const resizer = new Resizer(
|
||||
this.resizeContainer,
|
||||
CollapseDistributor,
|
||||
collapseConfig);
|
||||
resizer.setClassNames(classNames);
|
||||
return resizer;
|
||||
},
|
||||
|
||||
_loadResizerPreferences() {
|
||||
let lhsSize = window.localStorage.getItem("mx_lhs_size");
|
||||
if (lhsSize !== null) {
|
||||
lhsSize = parseInt(lhsSize, 10);
|
||||
} else {
|
||||
lhsSize = 350;
|
||||
}
|
||||
this.resizer.forHandleAt(0).resize(lhsSize);
|
||||
},
|
||||
|
||||
onAccountData: function(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"});
|
||||
}
|
||||
},
|
||||
|
||||
onSync: function(syncState, oldSyncState, data) {
|
||||
const oldErrCode = (
|
||||
this.state.syncErrorData &&
|
||||
this.state.syncErrorData.error &&
|
||||
this.state.syncErrorData.error.errcode
|
||||
);
|
||||
const newErrCode = data && data.error && data.error.errcode;
|
||||
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
|
||||
|
||||
if (syncState === 'ERROR') {
|
||||
this.setState({
|
||||
syncErrorData: data,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
syncErrorData: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
|
||||
this._updateServerNoticeEvents();
|
||||
}
|
||||
},
|
||||
|
||||
onRoomStateEvents: function(ev, state) {
|
||||
const roomLists = RoomListStore.getRoomLists();
|
||||
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
|
||||
this._updateServerNoticeEvents();
|
||||
}
|
||||
},
|
||||
|
||||
_updateServerNoticeEvents: async function() {
|
||||
const roomLists = RoomListStore.getRoomLists();
|
||||
if (!roomLists['m.server_notice']) return [];
|
||||
|
||||
const pinnedEvents = [];
|
||||
for (const room of roomLists['m.server_notice']) {
|
||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||
|
||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||
|
||||
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
|
||||
for (const eventId of pinnedEventIds) {
|
||||
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
|
||||
const ev = timeline.getEvents().find(ev => ev.getId() === eventId);
|
||||
if (ev) pinnedEvents.push(ev);
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
serverNoticeEvents: pinnedEvents,
|
||||
});
|
||||
},
|
||||
|
||||
_onPaste: function(ev) {
|
||||
let canReceiveInput = false;
|
||||
let element = ev.target;
|
||||
// test for all parents because the target can be a child of a contenteditable element
|
||||
while (!canReceiveInput && element) {
|
||||
canReceiveInput = canElementReceiveInput(element);
|
||||
element = element.parentElement;
|
||||
}
|
||||
if (!canReceiveInput) {
|
||||
// refocusing during a paste event will make the
|
||||
// paste end up in the newly focused element,
|
||||
// so dispatch synchronously before paste happens
|
||||
dis.dispatch({action: 'focus_composer'}, true);
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
SOME HACKERY BELOW:
|
||||
React optimizes event handlers, by always attaching only 1 handler to the document for a given type.
|
||||
It then internally determines the order in which React event handlers should be called,
|
||||
emulating the capture and bubbling phases the DOM also has.
|
||||
|
||||
But, as the native handler for React is always attached on the document,
|
||||
it will always run last for bubbling (first for capturing) handlers,
|
||||
and thus React basically has its own event phases, and will always run
|
||||
after (before for capturing) any native other event handlers (as they tend to be attached last).
|
||||
|
||||
So ideally one wouldn't mix React and native event handlers to have bubbling working as expected,
|
||||
but we do need a native event handler here on the document,
|
||||
to get keydown events when there is no focused element (target=body).
|
||||
|
||||
We also do need bubbling here to give child components a chance to call `stopPropagation()`,
|
||||
for keydown events it can handle itself, and shouldn't be redirected to the composer.
|
||||
|
||||
So we listen with React on this component to get any events on focused elements, and get bubbling working as expected.
|
||||
We also listen with a native listener on the document to get keydown events when no element is focused.
|
||||
Bubbling is irrelevant here as the target is the body element.
|
||||
*/
|
||||
_onReactKeyDown: function(ev) {
|
||||
// events caught while bubbling up on the root element
|
||||
// of this component, so something must be focused.
|
||||
this._onKeyDown(ev);
|
||||
},
|
||||
|
||||
_onNativeKeyDown: function(ev) {
|
||||
// only pass this if there is no focused element.
|
||||
// if there is, _onKeyDown will be called by the
|
||||
// react keydown handler that respects the react bubbling order.
|
||||
if (ev.target === document.body) {
|
||||
this._onKeyDown(ev);
|
||||
}
|
||||
},
|
||||
|
||||
_onKeyDown: function(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;
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.PAGE_UP:
|
||||
case Key.PAGE_DOWN:
|
||||
if (!hasModifier && !isModifier) {
|
||||
this._onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.HOME:
|
||||
case Key.END:
|
||||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
this._onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
case Key.K:
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'focus_room_filter',
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
case Key.BACKTICK:
|
||||
// Ideally this would be CTRL+P for "Profile", but that's
|
||||
// taken by the print dialog. CTRL+I for "Information"
|
||||
// was previously chosen but conflicted with italics in
|
||||
// composer, so CTRL+` it is
|
||||
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_top_left_menu',
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.SLASH:
|
||||
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) {
|
||||
KeyboardShortcuts.toggleDialog();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
dis.dispatch({
|
||||
action: 'view_room_delta',
|
||||
delta: ev.key === Key.ARROW_UP ? -1 : 1,
|
||||
unread: ev.shiftKey,
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.PERIOD:
|
||||
if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_right_panel',
|
||||
type: this.props.page_type === "room_view" ? "room" : "group",
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
} else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
// The above condition is crafted to _allow_ characters with Shift
|
||||
// already pressed (but not the Shift key down itself).
|
||||
|
||||
const isClickShortcut = ev.target !== document.body &&
|
||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||
|
||||
// Do not capture the context menu key to improve keyboard accessibility
|
||||
if (ev.key === Key.CONTEXT_MENU) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
dis.dispatch({action: 'focus_composer'}, true);
|
||||
ev.stopPropagation();
|
||||
// we should *not* preventDefault() here as
|
||||
// that would prevent typing in the now-focussed composer
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* dispatch a page-up/page-down/etc to the appropriate component
|
||||
* @param {Object} ev The key event
|
||||
*/
|
||||
_onScrollKeyPressed: function(ev) {
|
||||
if (this._roomView.current) {
|
||||
this._roomView.current.handleScrollKey(ev);
|
||||
}
|
||||
},
|
||||
|
||||
_onDragEnd: function(result) {
|
||||
// Dragged to an invalid destination, not onto a droppable
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dest = result.destination.droppableId;
|
||||
|
||||
if (dest === 'tag-panel-droppable') {
|
||||
// Could be "GroupTile +groupId:domain"
|
||||
const draggableId = result.draggableId.split(' ').pop();
|
||||
|
||||
// Dispatch synchronously so that the TagPanel receives an
|
||||
// optimistic update from TagOrderStore before the previous
|
||||
// state is shown.
|
||||
dis.dispatch(TagOrderActions.moveTag(
|
||||
this._matrixClient,
|
||||
draggableId,
|
||||
result.destination.index,
|
||||
), true);
|
||||
} else if (dest.startsWith('room-sub-list-droppable_')) {
|
||||
this._onRoomTileEndDrag(result);
|
||||
}
|
||||
},
|
||||
|
||||
_onRoomTileEndDrag: function(result) {
|
||||
let newTag = result.destination.droppableId.split('_')[1];
|
||||
let prevTag = result.source.droppableId.split('_')[1];
|
||||
if (newTag === 'undefined') newTag = undefined;
|
||||
if (prevTag === 'undefined') prevTag = undefined;
|
||||
|
||||
const roomId = result.draggableId.split('_')[1];
|
||||
|
||||
const oldIndex = result.source.index;
|
||||
const newIndex = result.destination.index;
|
||||
|
||||
dis.dispatch(RoomListActions.tagRoom(
|
||||
this._matrixClient,
|
||||
this._matrixClient.getRoom(roomId),
|
||||
prevTag, newTag,
|
||||
oldIndex, newIndex,
|
||||
), true);
|
||||
},
|
||||
|
||||
_onMouseDown: function(ev) {
|
||||
// When the panels are disabled, clicking on them results in a mouse event
|
||||
// which bubbles to certain elements in the tree. When this happens, close
|
||||
// any settings page that is currently open (user/room/group).
|
||||
if (this.props.leftDisabled && this.props.rightDisabled) {
|
||||
const targetClasses = new Set(ev.target.className.split(' '));
|
||||
if (
|
||||
targetClasses.has('mx_MatrixChat') ||
|
||||
targetClasses.has('mx_MatrixChat_middlePanel') ||
|
||||
targetClasses.has('mx_RoomView')
|
||||
) {
|
||||
this.setState({
|
||||
mouseDown: {
|
||||
x: ev.pageX,
|
||||
y: ev.pageY,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_onMouseUp: function(ev) {
|
||||
if (!this.state.mouseDown) return;
|
||||
|
||||
const deltaX = ev.pageX - this.state.mouseDown.x;
|
||||
const deltaY = ev.pageY - this.state.mouseDown.y;
|
||||
const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY));
|
||||
const maxRadius = 5; // People shouldn't be straying too far, hopefully
|
||||
|
||||
// Note: we track how far the user moved their mouse to help
|
||||
// combat against https://github.com/vector-im/riot-web/issues/7158
|
||||
|
||||
if (distance < maxRadius) {
|
||||
// This is probably a real click, and not a drag
|
||||
dis.dispatch({ action: 'close_settings' });
|
||||
}
|
||||
|
||||
// Always clear the mouseDown state to ensure we don't accidentally
|
||||
// use stale values due to the mouseDown checks.
|
||||
this.setState({mouseDown: null});
|
||||
},
|
||||
|
||||
_setResizeContainerRef(div) {
|
||||
this.resizeContainer = div;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const LeftPanel = sdk.getComponent('structures.LeftPanel');
|
||||
const RoomView = sdk.getComponent('structures.RoomView');
|
||||
const UserView = sdk.getComponent('structures.UserView');
|
||||
const GroupView = sdk.getComponent('structures.GroupView');
|
||||
const MyGroups = sdk.getComponent('structures.MyGroups');
|
||||
const ToastContainer = sdk.getComponent('structures.ToastContainer');
|
||||
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
|
||||
const CookieBar = sdk.getComponent('globals.CookieBar');
|
||||
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
|
||||
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
|
||||
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
|
||||
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
|
||||
|
||||
let pageElement;
|
||||
|
||||
switch (this.props.page_type) {
|
||||
case PageTypes.RoomView:
|
||||
pageElement = <RoomView
|
||||
ref={this._roomView}
|
||||
autoJoin={this.props.autoJoin}
|
||||
onRegistered={this.props.onRegistered}
|
||||
thirdPartyInvite={this.props.thirdPartyInvite}
|
||||
oobData={this.props.roomOobData}
|
||||
viaServers={this.props.viaServers}
|
||||
eventPixelOffset={this.props.initialEventPixelOffset}
|
||||
key={this.props.currentRoomId || 'roomview'}
|
||||
disabled={this.props.middleDisabled}
|
||||
ConferenceHandler={this.props.ConferenceHandler}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
break;
|
||||
|
||||
case PageTypes.MyGroups:
|
||||
pageElement = <MyGroups />;
|
||||
break;
|
||||
|
||||
case PageTypes.RoomDirectory:
|
||||
// handled by MatrixChat for now
|
||||
break;
|
||||
|
||||
case PageTypes.HomePage:
|
||||
pageElement = <HomePage />;
|
||||
break;
|
||||
|
||||
case PageTypes.UserView:
|
||||
pageElement = <UserView userId={this.props.currentUserId} />;
|
||||
break;
|
||||
case PageTypes.GroupView:
|
||||
pageElement = <GroupView
|
||||
groupId={this.props.currentGroupId}
|
||||
isNew={this.props.currentGroupIsNew}
|
||||
/>;
|
||||
break;
|
||||
}
|
||||
|
||||
const usageLimitEvent = this.state.serverNoticeEvents.find((e) => {
|
||||
return (
|
||||
e && e.getType() === 'm.room.message' &&
|
||||
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
|
||||
);
|
||||
});
|
||||
|
||||
let topBar;
|
||||
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
topBar = <ServerLimitBar kind='hard'
|
||||
adminContact={this.state.syncErrorData.error.data.admin_contact}
|
||||
limitType={this.state.syncErrorData.error.data.limit_type}
|
||||
/>;
|
||||
} else if (usageLimitEvent) {
|
||||
topBar = <ServerLimitBar kind='soft'
|
||||
adminContact={usageLimitEvent.getContent().admin_contact}
|
||||
limitType={usageLimitEvent.getContent().limit_type}
|
||||
/>;
|
||||
} else if (this.props.showCookieBar &&
|
||||
this.props.config.piwik &&
|
||||
navigator.doNotTrack !== "1"
|
||||
) {
|
||||
const policyUrl = this.props.config.piwik.policyUrl || null;
|
||||
topBar = <CookieBar policyUrl={policyUrl} />;
|
||||
} else if (this.props.hasNewVersion) {
|
||||
topBar = <NewVersionBar version={this.props.version} newVersion={this.props.newVersion}
|
||||
releaseNotes={this.props.newVersionReleaseNotes}
|
||||
/>;
|
||||
} else if (this.props.checkingForUpdate) {
|
||||
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
|
||||
} else if (this.state.userHasGeneratedPassword) {
|
||||
topBar = <PasswordNagBar />;
|
||||
} else if (this.props.showNotifierToolbar) {
|
||||
topBar = <MatrixToolbar />;
|
||||
}
|
||||
|
||||
let bodyClasses = 'mx_MatrixChat';
|
||||
if (topBar) {
|
||||
bodyClasses += ' mx_MatrixChat_toolbarShowing';
|
||||
}
|
||||
if (this.state.useCompactLayout) {
|
||||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||
}
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<div
|
||||
onPaste={this._onPaste}
|
||||
onKeyDown={this._onReactKeyDown}
|
||||
className='mx_MatrixChat_wrapper'
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
onMouseDown={this._onMouseDown}
|
||||
onMouseUp={this._onMouseUp}
|
||||
>
|
||||
{ topBar }
|
||||
<ToastContainer />
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
<div ref={this._setResizeContainerRef} className={bodyClasses}>
|
||||
<LeftPanel
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
<ResizeHandle />
|
||||
{ pageElement }
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default LoggedInView;
|
||||
653
src/components/structures/LoggedInView.tsx
Normal file
653
src/components/structures/LoggedInView.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018, 2020 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 * as React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
|
||||
import { Key } from '../../Keyboard';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import MediaDeviceHandler from '../../MediaDeviceHandler';
|
||||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { IMatrixClientCreds } from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
import ResizeHandle from '../views/elements/ResizeHandle';
|
||||
import { Resizer, CollapseDistributor } from '../../resizer';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
|
||||
import HomePage from "./HomePage";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import { DefaultTagID } from "../../stores/room-list/models";
|
||||
import {
|
||||
showToast as showServerLimitToast,
|
||||
hideToast as hideServerLimitToast,
|
||||
} from "../../toasts/ServerLimitToast";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import LeftPanel from "./LeftPanel";
|
||||
import CallContainer from '../views/voip/CallContainer';
|
||||
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
import NonUrgentToastContainer from "./NonUrgentToastContainer";
|
||||
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
|
||||
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import Modal from "../../Modal";
|
||||
import { ICollapseConfig } from "../../resizer/distributors/collapse";
|
||||
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
|
||||
import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBindingsManager';
|
||||
import { IOpts } from "../../createRoom";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
|
||||
import RoomView from './RoomView';
|
||||
import ToastContainer from './ToastContainer';
|
||||
import MyGroups from "./MyGroups";
|
||||
import UserView from "./UserView";
|
||||
import GroupView from "./GroupView";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
// 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.
|
||||
// NB. this is just for server notices rather than pinned messages in general.
|
||||
const MAX_PINNED_NOTICES_PER_ROOM = 2;
|
||||
|
||||
function canElementReceiveInput(el) {
|
||||
return el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT" ||
|
||||
!!el.getAttribute("contenteditable");
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
matrixClient: MatrixClient;
|
||||
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
|
||||
hideToSRUsers: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type?: string;
|
||||
autoJoin?: boolean;
|
||||
threepidInvite?: IThreepidInvite;
|
||||
roomOobData?: IOOBData;
|
||||
currentRoomId: string;
|
||||
collapseLhs: boolean;
|
||||
config: {
|
||||
piwik: {
|
||||
policyUrl: string;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
currentUserId?: string;
|
||||
currentGroupId?: string;
|
||||
currentGroupIsNew?: boolean;
|
||||
justRegistered?: boolean;
|
||||
roomJustCreatedOpts?: IOpts;
|
||||
}
|
||||
|
||||
interface IUsageLimit {
|
||||
// "hs_disabled" is NOT a specced string, but is used in Synapse
|
||||
// This is tracked over at https://github.com/matrix-org/synapse/issues/9237
|
||||
// eslint-disable-next-line camelcase
|
||||
limit_type: "monthly_active_user" | "hs_disabled" | string;
|
||||
// eslint-disable-next-line camelcase
|
||||
admin_contact?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
syncErrorData?: {
|
||||
error: {
|
||||
// This is not specced, but used in Synapse. See
|
||||
// https://github.com/matrix-org/synapse/issues/9237#issuecomment-768238922
|
||||
data: IUsageLimit;
|
||||
errcode: string;
|
||||
};
|
||||
};
|
||||
usageLimitDismissed: boolean;
|
||||
usageLimitEventContent?: IUsageLimit;
|
||||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
activeCalls: Array<MatrixCall>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is what our MatrixChat shows when we are logged in. The precise view is
|
||||
* determined by the page_type property.
|
||||
*
|
||||
* Currently it's very tightly coupled with MatrixChat. We should try to do
|
||||
* something about that.
|
||||
*
|
||||
* Components mounted below us can access the matrix client via the react context.
|
||||
*/
|
||||
@replaceableComponent("structures.LoggedInView")
|
||||
class LoggedInView extends React.Component<IProps, IState> {
|
||||
static displayName = 'LoggedInView';
|
||||
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
page_type: PropTypes.string.isRequired,
|
||||
onRoomCreated: PropTypes.func,
|
||||
|
||||
// Called with the credentials of a registered user (if they were a ROU that
|
||||
// transitioned to PWLU)
|
||||
onRegistered: PropTypes.func,
|
||||
|
||||
// and lots and lots of other stuff.
|
||||
};
|
||||
|
||||
protected readonly _matrixClient: MatrixClient;
|
||||
protected readonly _roomView: React.RefObject<any>;
|
||||
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
|
||||
protected compactLayoutWatcherRef: string;
|
||||
protected resizer: Resizer;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
syncErrorData: undefined,
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
usageLimitDismissed: false,
|
||||
activeCalls: [],
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
this._matrixClient = this.props.matrixClient;
|
||||
|
||||
MediaDeviceHandler.loadDevices();
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = React.createRef();
|
||||
this._resizeContainer = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
// Call `onSync` with the current state as well
|
||||
this.onSync(
|
||||
this._matrixClient.getSyncState(),
|
||||
null,
|
||||
this._matrixClient.getSyncStateData(),
|
||||
);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
|
||||
this.resizer = this._createResizer();
|
||||
this.resizer.attach();
|
||||
this._loadResizerPreferences();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this._onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
|
||||
this.resizer.detach();
|
||||
}
|
||||
|
||||
private onCallsChanged = () => {
|
||||
this.setState({
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
});
|
||||
};
|
||||
|
||||
canResetTimelineInRoom = (roomId) => {
|
||||
if (!this._roomView.current) {
|
||||
return true;
|
||||
}
|
||||
return this._roomView.current.canResetTimeline();
|
||||
};
|
||||
|
||||
_createResizer() {
|
||||
let size;
|
||||
let collapsed;
|
||||
const collapseConfig: ICollapseConfig = {
|
||||
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
|
||||
toggleSize: 206 - 50,
|
||||
onCollapsed: (_collapsed) => {
|
||||
collapsed = _collapsed;
|
||||
if (_collapsed) {
|
||||
dis.dispatch({ action: "hide_left_panel" });
|
||||
window.localStorage.setItem("mx_lhs_size", '0');
|
||||
} else {
|
||||
dis.dispatch({ action: "show_left_panel" });
|
||||
}
|
||||
},
|
||||
onResized: (_size) => {
|
||||
size = _size;
|
||||
this.props.resizeNotifier.notifyLeftHandleResized();
|
||||
},
|
||||
onResizeStart: () => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
},
|
||||
onResizeStop: () => {
|
||||
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
},
|
||||
isItemCollapsed: domNode => {
|
||||
return domNode.classList.contains("mx_LeftPanel_minimized");
|
||||
},
|
||||
};
|
||||
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
|
||||
resizer.setClassNames({
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
});
|
||||
return resizer;
|
||||
}
|
||||
|
||||
_loadResizerPreferences() {
|
||||
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10);
|
||||
if (isNaN(lhsSize)) {
|
||||
lhsSize = 350;
|
||||
}
|
||||
this.resizer.forHandleAt(0).resize(lhsSize);
|
||||
}
|
||||
|
||||
onAccountData = (event) => {
|
||||
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 &&
|
||||
this.state.syncErrorData.error &&
|
||||
this.state.syncErrorData.error.errcode
|
||||
);
|
||||
const newErrCode = data && data.error && data.error.errcode;
|
||||
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
|
||||
|
||||
if (syncState === 'ERROR') {
|
||||
this.setState({
|
||||
syncErrorData: data,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
syncErrorData: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
|
||||
this._updateServerNoticeEvents();
|
||||
} else {
|
||||
this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
|
||||
}
|
||||
};
|
||||
|
||||
onRoomStateEvents = (ev, state) => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) {
|
||||
this._updateServerNoticeEvents();
|
||||
}
|
||||
};
|
||||
|
||||
private onUsageLimitDismissed = () => {
|
||||
this.setState({
|
||||
usageLimitDismissed: true,
|
||||
});
|
||||
};
|
||||
|
||||
_calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
|
||||
const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
|
||||
if (error) {
|
||||
usageLimitEventContent = syncError.error.data;
|
||||
}
|
||||
|
||||
// usageLimitDismissed is true when the user has explicitly hidden the toast
|
||||
// and it will be reset to false if a *new* usage alert comes in.
|
||||
if (usageLimitEventContent && this.state.usageLimitDismissed) {
|
||||
showServerLimitToast(
|
||||
usageLimitEventContent.limit_type,
|
||||
this.onUsageLimitDismissed,
|
||||
usageLimitEventContent.admin_contact,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
hideServerLimitToast();
|
||||
}
|
||||
}
|
||||
|
||||
_updateServerNoticeEvents = async () => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (!serverNoticeList) return [];
|
||||
|
||||
const events = [];
|
||||
let pinnedEventTs = 0;
|
||||
for (const room of serverNoticeList) {
|
||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||
|
||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||
pinnedEventTs = pinStateEvent.getTs();
|
||||
|
||||
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
|
||||
for (const eventId of pinnedEventIds) {
|
||||
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
|
||||
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
|
||||
if (event) events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (pinnedEventTs && this.state.usageLimitEventTs > pinnedEventTs) {
|
||||
// We've processed a newer event than this one, so ignore it.
|
||||
return;
|
||||
}
|
||||
|
||||
const usageLimitEvent = events.find((e) => {
|
||||
return (
|
||||
e && e.getType() === 'm.room.message' &&
|
||||
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
|
||||
);
|
||||
});
|
||||
const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent();
|
||||
this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
|
||||
this.setState({
|
||||
usageLimitEventContent,
|
||||
usageLimitEventTs: pinnedEventTs,
|
||||
// This is a fresh toast, we can show toasts again
|
||||
usageLimitDismissed: false,
|
||||
});
|
||||
};
|
||||
|
||||
_onPaste = (ev) => {
|
||||
let canReceiveInput = false;
|
||||
let element = ev.target;
|
||||
// test for all parents because the target can be a child of a contenteditable element
|
||||
while (!canReceiveInput && element) {
|
||||
canReceiveInput = canElementReceiveInput(element);
|
||||
element = element.parentElement;
|
||||
}
|
||||
if (!canReceiveInput) {
|
||||
// refocusing during a paste event will make the
|
||||
// paste end up in the newly focused element,
|
||||
// so dispatch synchronously before paste happens
|
||||
dis.fire(Action.FocusSendMessageComposer, true);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
SOME HACKERY BELOW:
|
||||
React optimizes event handlers, by always attaching only 1 handler to the document for a given type.
|
||||
It then internally determines the order in which React event handlers should be called,
|
||||
emulating the capture and bubbling phases the DOM also has.
|
||||
|
||||
But, as the native handler for React is always attached on the document,
|
||||
it will always run last for bubbling (first for capturing) handlers,
|
||||
and thus React basically has its own event phases, and will always run
|
||||
after (before for capturing) any native other event handlers (as they tend to be attached last).
|
||||
|
||||
So ideally one wouldn't mix React and native event handlers to have bubbling working as expected,
|
||||
but we do need a native event handler here on the document,
|
||||
to get keydown events when there is no focused element (target=body).
|
||||
|
||||
We also do need bubbling here to give child components a chance to call `stopPropagation()`,
|
||||
for keydown events it can handle itself, and shouldn't be redirected to the composer.
|
||||
|
||||
So we listen with React on this component to get any events on focused elements, and get bubbling working as expected.
|
||||
We also listen with a native listener on the document to get keydown events when no element is focused.
|
||||
Bubbling is irrelevant here as the target is the body element.
|
||||
*/
|
||||
_onReactKeyDown = (ev) => {
|
||||
// events caught while bubbling up on the root element
|
||||
// of this component, so something must be focused.
|
||||
this._onKeyDown(ev);
|
||||
};
|
||||
|
||||
_onNativeKeyDown = (ev) => {
|
||||
// only pass this if there is no focused element.
|
||||
// if there is, _onKeyDown will be called by the
|
||||
// react keydown handler that respects the react bubbling order.
|
||||
if (ev.target === document.body) {
|
||||
this._onKeyDown(ev);
|
||||
}
|
||||
};
|
||||
|
||||
_onKeyDown = (ev) => {
|
||||
let handled = false;
|
||||
|
||||
const roomAction = getKeyBindingsManager().getRoomAction(ev);
|
||||
switch (roomAction) {
|
||||
case RoomAction.ScrollUp:
|
||||
case RoomAction.RoomScrollDown:
|
||||
case RoomAction.JumpToFirstMessage:
|
||||
case RoomAction.JumpToLatestMessage:
|
||||
// pass the event down to the scroll panel
|
||||
this._onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
break;
|
||||
case RoomAction.FocusSearch:
|
||||
dis.dispatch({
|
||||
action: 'focus_search',
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const navAction = getKeyBindingsManager().getNavigationAction(ev);
|
||||
switch (navAction) {
|
||||
case NavigationAction.FocusRoomSearch:
|
||||
dis.dispatch({
|
||||
action: 'focus_room_filter',
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
case NavigationAction.ToggleUserMenu:
|
||||
dis.fire(Action.ToggleUserMenu);
|
||||
handled = true;
|
||||
break;
|
||||
case NavigationAction.ToggleShortCutDialog:
|
||||
KeyboardShortcuts.toggleDialog();
|
||||
handled = true;
|
||||
break;
|
||||
case NavigationAction.GoToHome:
|
||||
dis.dispatch({
|
||||
action: 'view_home_page',
|
||||
});
|
||||
Modal.closeCurrentModal("homeKeyboardShortcut");
|
||||
handled = true;
|
||||
break;
|
||||
case NavigationAction.ToggleRoomSidePanel:
|
||||
if (this.props.page_type === "room_view" || this.props.page_type === "group_view") {
|
||||
dis.dispatch<ToggleRightPanelPayload>({
|
||||
action: Action.ToggleRightPanel,
|
||||
type: this.props.page_type === "room_view" ? "room" : "group",
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
case NavigationAction.SelectPrevRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: false,
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
case NavigationAction.SelectNextRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: false,
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
case NavigationAction.SelectPrevUnreadRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: true,
|
||||
});
|
||||
break;
|
||||
case NavigationAction.SelectNextUnreadRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: true,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// if we do not have a handler for it, pass it to the platform which might
|
||||
handled = PlatformPeg.get().onKeyDown(ev);
|
||||
}
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
// The above condition is crafted to _allow_ characters with Shift
|
||||
// already pressed (but not the Shift key down itself).
|
||||
|
||||
const isClickShortcut = ev.target !== document.body &&
|
||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||
|
||||
// Do not capture the context menu key to improve keyboard accessibility
|
||||
if (ev.key === Key.CONTEXT_MENU) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
dis.fire(Action.FocusSendMessageComposer, true);
|
||||
ev.stopPropagation();
|
||||
// we should *not* preventDefault() here as
|
||||
// that would prevent typing in the now-focussed composer
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* dispatch a page-up/page-down/etc to the appropriate component
|
||||
* @param {Object} ev The key event
|
||||
*/
|
||||
_onScrollKeyPressed = (ev) => {
|
||||
if (this._roomView.current) {
|
||||
this._roomView.current.handleScrollKey(ev);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let pageElement;
|
||||
|
||||
switch (this.props.page_type) {
|
||||
case PageTypes.RoomView:
|
||||
pageElement = <RoomView
|
||||
ref={this._roomView}
|
||||
onRegistered={this.props.onRegistered}
|
||||
threepidInvite={this.props.threepidInvite}
|
||||
oobData={this.props.roomOobData}
|
||||
key={this.props.currentRoomId || 'roomview'}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
justCreatedOpts={this.props.roomJustCreatedOpts}
|
||||
/>;
|
||||
break;
|
||||
|
||||
case PageTypes.MyGroups:
|
||||
pageElement = <MyGroups />;
|
||||
break;
|
||||
|
||||
case PageTypes.RoomDirectory:
|
||||
// handled by MatrixChat for now
|
||||
break;
|
||||
|
||||
case PageTypes.HomePage:
|
||||
pageElement = <HomePage justRegistered={this.props.justRegistered} />;
|
||||
break;
|
||||
|
||||
case PageTypes.UserView:
|
||||
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
break;
|
||||
case PageTypes.GroupView:
|
||||
pageElement = <GroupView
|
||||
groupId={this.props.currentGroupId}
|
||||
isNew={this.props.currentGroupIsNew}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
break;
|
||||
}
|
||||
|
||||
let bodyClasses = 'mx_MatrixChat';
|
||||
if (this.state.useCompactLayout) {
|
||||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||
}
|
||||
|
||||
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
|
||||
return (
|
||||
<AudioFeedArrayForCall call={call} key={call.callId} />
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<div
|
||||
onPaste={this._onPaste}
|
||||
onKeyDown={this._onReactKeyDown}
|
||||
className='mx_MatrixChat_wrapper'
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
>
|
||||
<ToastContainer />
|
||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||
{ SpaceStore.spacesEnabled ? <SpacePanel /> : null }
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
<ResizeHandle />
|
||||
{ pageElement }
|
||||
</div>
|
||||
</div>
|
||||
<CallContainer />
|
||||
<NonUrgentToastContainer />
|
||||
<HostSignupContainer />
|
||||
{audioFeedArraysForCalls}
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LoggedInView;
|
||||
@@ -16,77 +16,35 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ResizeHandle from '../views/elements/ResizeHandle';
|
||||
import {Resizer, FixedDistributor} from '../../resizer';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("structures.MainSplit")
|
||||
export default class MainSplit extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._setResizeContainerRef = this._setResizeContainerRef.bind(this);
|
||||
this._onResized = this._onResized.bind(this);
|
||||
}
|
||||
_onResizeStart = () => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
};
|
||||
|
||||
_onResized(size) {
|
||||
window.localStorage.setItem("mx_rhs_size", size);
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.notifyRightHandleResized();
|
||||
}
|
||||
}
|
||||
_onResize = () => {
|
||||
this.props.resizeNotifier.notifyRightHandleResized();
|
||||
};
|
||||
|
||||
_createResizer() {
|
||||
const classNames = {
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
};
|
||||
const resizer = new Resizer(
|
||||
this.resizeContainer,
|
||||
FixedDistributor,
|
||||
{onResized: this._onResized},
|
||||
);
|
||||
resizer.setClassNames(classNames);
|
||||
let rhsSize = window.localStorage.getItem("mx_rhs_size");
|
||||
if (rhsSize !== null) {
|
||||
rhsSize = parseInt(rhsSize, 10);
|
||||
} else {
|
||||
_onResizeStop = (event, direction, refToElement, delta) => {
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width);
|
||||
};
|
||||
|
||||
_loadSidePanelSize() {
|
||||
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
|
||||
|
||||
if (isNaN(rhsSize)) {
|
||||
rhsSize = 350;
|
||||
}
|
||||
resizer.forHandleAt(0).resize(rhsSize);
|
||||
|
||||
resizer.attach();
|
||||
this.resizer = resizer;
|
||||
}
|
||||
|
||||
_setResizeContainerRef(div) {
|
||||
this.resizeContainer = div;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.panel) {
|
||||
this._createResizer();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.resizer) {
|
||||
this.resizer.detach();
|
||||
this.resizer = null;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const wasPanelSet = this.props.panel && !prevProps.panel;
|
||||
const wasPanelCleared = !this.props.panel && prevProps.panel;
|
||||
|
||||
if (this.resizeContainer && wasPanelSet) {
|
||||
// The resizer can only be created when **both** expanded and the panel is
|
||||
// set. Once both are true, the container ref will mount, which is required
|
||||
// for the resizer to work.
|
||||
this._createResizer();
|
||||
} else if (this.resizer && wasPanelCleared) {
|
||||
this.resizer.detach();
|
||||
this.resizer = null;
|
||||
}
|
||||
return {
|
||||
height: "100%",
|
||||
width: rhsSize,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -97,13 +55,31 @@ export default class MainSplit extends React.Component {
|
||||
|
||||
let children;
|
||||
if (hasResizer) {
|
||||
children = <React.Fragment>
|
||||
<ResizeHandle reverse={true} />
|
||||
children = <Resizable
|
||||
defaultSize={this._loadSidePanelSize()}
|
||||
minWidth={264}
|
||||
maxWidth="50%"
|
||||
enable={{
|
||||
top: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
left: true,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
onResizeStart={this._onResizeStart}
|
||||
onResize={this._onResize}
|
||||
onResizeStop={this._onResizeStop}
|
||||
className="mx_RightPanel_ResizeWrapper"
|
||||
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
|
||||
>
|
||||
{ panelView }
|
||||
</React.Fragment>;
|
||||
</Resizable>;
|
||||
}
|
||||
|
||||
return <div className="mx_MainSplit" ref={hasResizer ? this._setResizeContainerRef : undefined}>
|
||||
return <div className="mx_MainSplit">
|
||||
{ bodyView }
|
||||
{ children }
|
||||
</div>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1285
src/components/structures/MessagePanel.tsx
Normal file
1285
src/components/structures/MessagePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -16,50 +17,48 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from '../../dispatcher';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import AccessibleButton from '../views/elements/AccessibleButton';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import BetaCard from "../views/beta/BetaCard";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MyGroups',
|
||||
@replaceableComponent("structures.MyGroups")
|
||||
export default class MyGroups extends React.Component {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
groups: null,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
groups: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
statics: {
|
||||
contextType: MatrixClientContext,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this._fetch();
|
||||
},
|
||||
}
|
||||
|
||||
_onCreateGroupClick: function() {
|
||||
dis.dispatch({action: 'view_create_group'});
|
||||
},
|
||||
_onCreateGroupClick = () => {
|
||||
dis.dispatch({ action: 'view_create_group' });
|
||||
};
|
||||
|
||||
_fetch: function() {
|
||||
_fetch() {
|
||||
this.context.getJoinedGroups().then((result) => {
|
||||
this.setState({groups: result.groups, error: null});
|
||||
this.setState({ groups: result.groups, error: null });
|
||||
}, (err) => {
|
||||
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') {
|
||||
// Indicate that the guest isn't in any groups (which should be true)
|
||||
this.setState({groups: [], error: null});
|
||||
this.setState({ groups: [], error: null });
|
||||
return;
|
||||
}
|
||||
this.setState({groups: null, error: err});
|
||||
this.setState({ groups: null, error: err });
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
const GroupTile = sdk.getComponent("groups.GroupTile");
|
||||
@@ -77,13 +76,13 @@ 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>
|
||||
{ _t(
|
||||
"To set up a filter, drag a community avatar over to the filter panel on " +
|
||||
"the far left hand side of the screen. You can click on an avatar in the " +
|
||||
"You can click on an avatar in the " +
|
||||
"filter panel at any time to see only the rooms and people associated " +
|
||||
"with that community.",
|
||||
) }
|
||||
@@ -124,7 +123,7 @@ export default createReactClass({
|
||||
</div>
|
||||
{/*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
|
||||
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
|
||||
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="50" height="50" />
|
||||
<img src={require("../../../res/img/icons-create-room.svg")} width="50" height="50" />
|
||||
</AccessibleButton>
|
||||
<div className="mx_MyGroups_headerCard_content">
|
||||
<div className="mx_MyGroups_headerCard_header">
|
||||
@@ -140,10 +139,11 @@ export default createReactClass({
|
||||
</div>
|
||||
</div>*/}
|
||||
</div>
|
||||
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
|
||||
<div className="mx_MyGroups_content">
|
||||
{ contentHeader }
|
||||
{ content }
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
65
src/components/structures/NonUrgentToastContainer.tsx
Normal file
65
src/components/structures/NonUrgentToastContainer.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 { ComponentClass } from "../../@types/common";
|
||||
import NonUrgentToastStore from "../../stores/NonUrgentToastStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
toasts: ComponentClass[];
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.NonUrgentToastContainer")
|
||||
export default class NonUrgentToastContainer extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
toasts: NonUrgentToastStore.instance.components,
|
||||
};
|
||||
|
||||
NonUrgentToastStore.instance.on(UPDATE_EVENT, this.onUpdateToasts);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
NonUrgentToastStore.instance.off(UPDATE_EVENT, this.onUpdateToasts);
|
||||
}
|
||||
|
||||
private onUpdateToasts = () => {
|
||||
this.setState({ toasts: NonUrgentToastStore.instance.components });
|
||||
};
|
||||
|
||||
public render() {
|
||||
const toasts = this.state.toasts.map((t, i) => {
|
||||
return (
|
||||
<div className="mx_NonUrgentToastContainer_toast" key={`toast-${i}`}>
|
||||
{React.createElement(t, {})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_NonUrgentToastContainer" role="alert">
|
||||
{toasts}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 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 from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../languageHandler';
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import * as sdk from "../../index";
|
||||
|
||||
/*
|
||||
* Component which shows the global notification list using a TimelinePanel
|
||||
*/
|
||||
const NotificationPanel = createReactClass({
|
||||
displayName: 'NotificationPanel',
|
||||
|
||||
propTypes: {
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
||||
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
|
||||
if (timelineSet) {
|
||||
return (
|
||||
<div className="mx_NotificationPanel" role="tabpanel">
|
||||
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview={false}
|
||||
tileShape="notif"
|
||||
empty={_t('You have no visible notifications')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
console.error("No notifTimelineSet available!");
|
||||
return (
|
||||
<div className="mx_NotificationPanel" role="tabpanel">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default NotificationPanel;
|
||||
66
src/components/structures/NotificationPanel.tsx
Normal file
66
src/components/structures/NotificationPanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { _t } from '../../languageHandler';
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { TileShape } from "../views/rooms/EventTile";
|
||||
|
||||
interface IProps {
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
/*
|
||||
* Component which shows the global notification list using a TimelinePanel
|
||||
*/
|
||||
@replaceableComponent("structures.NotificationPanel")
|
||||
export default class NotificationPanel extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
|
||||
<h2>{_t('You’re all caught up')}</h2>
|
||||
<p>{_t('You have no visible notifications.')}</p>
|
||||
</div>);
|
||||
|
||||
let content;
|
||||
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
|
||||
if (timelineSet) {
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
content = (
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview={false}
|
||||
tileShape={TileShape.Notif}
|
||||
empty={emptyState}
|
||||
alwaysShowTimestamps={true}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
console.error("No notifTimelineSet available!");
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
|
||||
{ content }
|
||||
</BaseCard>;
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import RateLimitedFunc from '../../ratelimitedfunc';
|
||||
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import {RIGHT_PANEL_PHASES, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
||||
export default class RightPanel extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
roomId: PropTypes.string, // if showing panels for a given room, this is set
|
||||
groupId: PropTypes.string, // if showing panels for a given group, this is set
|
||||
user: PropTypes.object, // used if we know the user ahead of opening the panel
|
||||
};
|
||||
}
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
phase: this._getPhaseFromProps(),
|
||||
isUserPrivilegedInGroup: null,
|
||||
member: this._getUserForPanel(),
|
||||
verificationRequest: RightPanelStore.getSharedInstance().roomPanelPhaseParams.verificationRequest,
|
||||
};
|
||||
this.onAction = this.onAction.bind(this);
|
||||
this.onRoomStateMember = this.onRoomStateMember.bind(this);
|
||||
this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this);
|
||||
this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this);
|
||||
this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this);
|
||||
|
||||
this._delayedUpdate = new RateLimitedFunc(() => {
|
||||
this.forceUpdate();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Helper function to split out the logic for _getPhaseFromProps() and the constructor
|
||||
// as both are called at the same time in the constructor.
|
||||
_getUserForPanel() {
|
||||
if (this.state && this.state.member) return this.state.member;
|
||||
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
|
||||
return this.props.user || lastParams['member'];
|
||||
}
|
||||
|
||||
// gets the current phase from the props and also maybe the store
|
||||
_getPhaseFromProps() {
|
||||
const rps = RightPanelStore.getSharedInstance();
|
||||
const userForPanel = this._getUserForPanel();
|
||||
if (this.props.groupId) {
|
||||
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) {
|
||||
dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.GroupMemberList});
|
||||
return RIGHT_PANEL_PHASES.GroupMemberList;
|
||||
}
|
||||
return rps.groupPanelPhase;
|
||||
} else if (userForPanel) {
|
||||
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state
|
||||
// from its props and some from a store, except if the contents of the store changes
|
||||
// while it's mounted in which case it replaces all of its state with that of the store,
|
||||
// except it uses a dispatch instead of a normal store listener?
|
||||
// Unfortunately rewriting this would almost certainly break showing the right panel
|
||||
// in some of the many cases, and I don't have time to re-architect it and test all
|
||||
// the flows now, so adding yet another special case so if the store thinks there is
|
||||
// a verification going on for the member we're displaying, we show that, otherwise
|
||||
// we race if a verification is started while the panel isn't displayed because we're
|
||||
// not mounted in time to get the dispatch.
|
||||
// Until then, let this code serve as a warning from history.
|
||||
if (
|
||||
rps.roomPanelPhaseParams.member &&
|
||||
userForPanel.userId === rps.roomPanelPhaseParams.member.userId &&
|
||||
rps.roomPanelPhaseParams.verificationRequest
|
||||
) {
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
return RIGHT_PANEL_PHASES.RoomMemberInfo;
|
||||
} else {
|
||||
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) {
|
||||
dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.RoomMemberList});
|
||||
return RIGHT_PANEL_PHASES.RoomMemberList;
|
||||
}
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
const cli = this.context;
|
||||
cli.on("RoomState.members", this.onRoomStateMember);
|
||||
this._initGroupStore(this.props.groupId);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
if (this.context) {
|
||||
this.context.removeListener("RoomState.members", this.onRoomStateMember);
|
||||
}
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
this._initGroupStore(newProps.groupId);
|
||||
}
|
||||
}
|
||||
|
||||
_initGroupStore(groupId) {
|
||||
if (!groupId) return;
|
||||
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
|
||||
}
|
||||
|
||||
_unregisterGroupStore() {
|
||||
GroupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
}
|
||||
|
||||
onGroupStoreUpdated() {
|
||||
this.setState({
|
||||
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
|
||||
});
|
||||
}
|
||||
|
||||
onInviteToGroupButtonClick() {
|
||||
showGroupInviteDialog(this.props.groupId).then(() => {
|
||||
this.setState({
|
||||
phase: RIGHT_PANEL_PHASES.GroupMemberList,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onAddRoomToGroupButtonClick() {
|
||||
showGroupAddRoomDialog(this.props.groupId).then(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
onRoomStateMember(ev, state, member) {
|
||||
if (member.roomId !== this.props.roomId) {
|
||||
return;
|
||||
}
|
||||
// redraw the badge on the membership list
|
||||
if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList && member.roomId === this.props.roomId) {
|
||||
this._delayedUpdate();
|
||||
} else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo && member.roomId === this.props.roomId &&
|
||||
member.userId === this.state.member.userId) {
|
||||
// refresh the member info (e.g. new power level)
|
||||
this._delayedUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
onAction(payload) {
|
||||
if (payload.action === "after_right_panel_phase_change") {
|
||||
this.setState({
|
||||
phase: payload.phase,
|
||||
groupRoomId: payload.groupRoomId,
|
||||
groupId: payload.groupId,
|
||||
member: payload.member,
|
||||
event: payload.event,
|
||||
verificationRequest: payload.verificationRequest,
|
||||
verificationRequestPromise: payload.verificationRequestPromise,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const MemberList = sdk.getComponent('rooms.MemberList');
|
||||
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
|
||||
const UserInfo = sdk.getComponent('right_panel.UserInfo');
|
||||
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
|
||||
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
|
||||
const FilePanel = sdk.getComponent('structures.FilePanel');
|
||||
|
||||
const GroupMemberList = sdk.getComponent('groups.GroupMemberList');
|
||||
const GroupMemberInfo = sdk.getComponent('groups.GroupMemberInfo');
|
||||
const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
|
||||
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
|
||||
|
||||
let panel = <div />;
|
||||
|
||||
switch (this.state.phase) {
|
||||
case RIGHT_PANEL_PHASES.RoomMemberList:
|
||||
if (this.props.roomId) {
|
||||
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
|
||||
}
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.GroupMemberList:
|
||||
if (this.props.groupId) {
|
||||
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
}
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.GroupRoomList:
|
||||
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.RoomMemberInfo:
|
||||
case RIGHT_PANEL_PHASES.EncryptionPanel:
|
||||
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
|
||||
const onClose = () => {
|
||||
dis.dispatch({
|
||||
action: "view_user",
|
||||
member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null,
|
||||
});
|
||||
};
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
roomId={this.props.roomId}
|
||||
key={this.props.roomId || this.state.member.userId}
|
||||
onClose={onClose}
|
||||
phase={this.state.phase}
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
verificationRequestPromise={this.state.verificationRequestPromise}
|
||||
/>;
|
||||
} else {
|
||||
panel = <MemberInfo
|
||||
member={this.state.member}
|
||||
key={this.props.roomId || this.state.member.userId}
|
||||
/>;
|
||||
}
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.Room3pidMemberInfo:
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.GroupMemberInfo:
|
||||
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
|
||||
const onClose = () => {
|
||||
dis.dispatch({
|
||||
action: "view_user",
|
||||
member: null,
|
||||
});
|
||||
};
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.userId}
|
||||
onClose={onClose} />;
|
||||
} else {
|
||||
panel = (
|
||||
<GroupMemberInfo
|
||||
groupMember={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.user_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.GroupRoomInfo:
|
||||
panel = <GroupRoomInfo
|
||||
groupRoomId={this.state.groupRoomId}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.groupRoomId} />;
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.NotificationPanel:
|
||||
panel = <NotificationPanel />;
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.FilePanel:
|
||||
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
break;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RightPanel", "mx_fadable", {
|
||||
"collapsed": this.props.collapsed,
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
"dark-panel": true,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className={classes} id="mx_RightPanel">
|
||||
{ panel }
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
326
src/components/structures/RightPanel.tsx
Normal file
326
src/components/structures/RightPanel.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
import {
|
||||
RIGHT_PANEL_PHASES_NO_ARGS,
|
||||
RIGHT_PANEL_SPACE_PHASES,
|
||||
RightPanelPhases,
|
||||
} from "../../stores/RightPanelStorePhases";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
|
||||
import WidgetCard from "../views/right_panel/WidgetCard";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import MemberList from "../views/rooms/MemberList";
|
||||
import GroupMemberList from "../views/groups/GroupMemberList";
|
||||
import GroupRoomList from "../views/groups/GroupRoomList";
|
||||
import GroupRoomInfo from "../views/groups/GroupRoomInfo";
|
||||
import UserInfo from "../views/right_panel/UserInfo";
|
||||
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
|
||||
import FilePanel from "./FilePanel";
|
||||
import NotificationPanel from "./NotificationPanel";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
|
||||
import { throttle } from 'lodash';
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
interface IProps {
|
||||
room?: Room; // if showing panels for a given room, this is set
|
||||
groupId?: string; // if showing panels for a given group, this is set
|
||||
user?: User; // used if we know the user ahead of opening the panel
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase: RightPanelPhases;
|
||||
isUserPrivilegedInGroup?: boolean;
|
||||
member?: RoomMember;
|
||||
verificationRequest?: VerificationRequest;
|
||||
verificationRequestPromise?: Promise<VerificationRequest>;
|
||||
space?: Room;
|
||||
widgetId?: string;
|
||||
groupRoomId?: string;
|
||||
groupId?: string;
|
||||
event: MatrixEvent;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.RightPanel")
|
||||
export default class RightPanel extends React.Component<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
private dispatcherRef: string;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
...RightPanelStore.getSharedInstance().roomPanelPhaseParams,
|
||||
phase: this.getPhaseFromProps(),
|
||||
isUserPrivilegedInGroup: null,
|
||||
member: this.getUserForPanel(),
|
||||
};
|
||||
}
|
||||
|
||||
private readonly delayedUpdate = throttle((): void => {
|
||||
this.forceUpdate();
|
||||
}, 500, { leading: true, trailing: true });
|
||||
|
||||
// Helper function to split out the logic for getPhaseFromProps() and the constructor
|
||||
// as both are called at the same time in the constructor.
|
||||
private getUserForPanel() {
|
||||
if (this.state && this.state.member) return this.state.member;
|
||||
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
|
||||
return this.props.user || lastParams['member'];
|
||||
}
|
||||
|
||||
// gets the current phase from the props and also maybe the store
|
||||
private getPhaseFromProps() {
|
||||
const rps = RightPanelStore.getSharedInstance();
|
||||
const userForPanel = this.getUserForPanel();
|
||||
if (this.props.groupId) {
|
||||
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) {
|
||||
dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList });
|
||||
return RightPanelPhases.GroupMemberList;
|
||||
}
|
||||
return rps.groupPanelPhase;
|
||||
} else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom()
|
||||
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
|
||||
) {
|
||||
return RightPanelPhases.SpaceMemberList;
|
||||
} else if (userForPanel) {
|
||||
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state
|
||||
// from its props and some from a store, except if the contents of the store changes
|
||||
// while it's mounted in which case it replaces all of its state with that of the store,
|
||||
// except it uses a dispatch instead of a normal store listener?
|
||||
// Unfortunately rewriting this would almost certainly break showing the right panel
|
||||
// in some of the many cases, and I don't have time to re-architect it and test all
|
||||
// the flows now, so adding yet another special case so if the store thinks there is
|
||||
// a verification going on for the member we're displaying, we show that, otherwise
|
||||
// we race if a verification is started while the panel isn't displayed because we're
|
||||
// not mounted in time to get the dispatch.
|
||||
// Until then, let this code serve as a warning from history.
|
||||
if (
|
||||
rps.roomPanelPhaseParams.member &&
|
||||
userForPanel.userId === rps.roomPanelPhaseParams.member.userId &&
|
||||
rps.roomPanelPhaseParams.verificationRequest
|
||||
) {
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
return RightPanelPhases.RoomMemberInfo;
|
||||
}
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
const cli = this.context;
|
||||
cli.on("RoomState.members", this.onRoomStateMember);
|
||||
this.initGroupStore(this.props.groupId);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
if (this.context) {
|
||||
this.context.removeListener("RoomState.members", this.onRoomStateMember);
|
||||
}
|
||||
this.unregisterGroupStore();
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this.unregisterGroupStore();
|
||||
this.initGroupStore(newProps.groupId);
|
||||
}
|
||||
}
|
||||
|
||||
private initGroupStore(groupId: string) {
|
||||
if (!groupId) return;
|
||||
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
|
||||
}
|
||||
|
||||
private unregisterGroupStore() {
|
||||
GroupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
}
|
||||
|
||||
private onGroupStoreUpdated = () => {
|
||||
this.setState({
|
||||
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
|
||||
});
|
||||
};
|
||||
|
||||
private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
|
||||
if (!this.props.room || member.roomId !== this.props.room.roomId) {
|
||||
return;
|
||||
}
|
||||
// redraw the badge on the membership list
|
||||
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) {
|
||||
this.delayedUpdate();
|
||||
} else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId &&
|
||||
member.userId === this.state.member.userId) {
|
||||
// refresh the member info (e.g. new power level)
|
||||
this.delayedUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === Action.AfterRightPanelPhaseChange) {
|
||||
this.setState({
|
||||
phase: payload.phase,
|
||||
groupRoomId: payload.groupRoomId,
|
||||
groupId: payload.groupId,
|
||||
member: payload.member,
|
||||
event: payload.event,
|
||||
verificationRequest: payload.verificationRequest,
|
||||
verificationRequestPromise: payload.verificationRequestPromise,
|
||||
widgetId: payload.widgetId,
|
||||
space: payload.space,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onClose = () => {
|
||||
// XXX: There are three different ways of 'closing' this panel depending on what state
|
||||
// things are in... this knows far more than it should do about the state of the rest
|
||||
// of the app and is generally a bit silly.
|
||||
if (this.props.user) {
|
||||
// If we have a user prop then we're displaying a user from the 'user' page type
|
||||
// in LoggedInView, so need to change the page type to close the panel (we switch
|
||||
// to the home page which is not obviously the correct thing to do, but I'm not sure
|
||||
// anything else is - we could hide the close button altogether?)
|
||||
dis.dispatch({
|
||||
action: "view_home_page",
|
||||
});
|
||||
} else if (
|
||||
this.state.phase === RightPanelPhases.EncryptionPanel &&
|
||||
this.state.verificationRequest && this.state.verificationRequest.pending
|
||||
) {
|
||||
// When the user clicks close on the encryption panel cancel the pending request first if any
|
||||
this.state.verificationRequest.cancel();
|
||||
} else {
|
||||
// the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
|
||||
dis.dispatch({
|
||||
action: Action.ToggleRightPanel,
|
||||
type: this.props.groupId ? "group" : "room",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let panel = <div />;
|
||||
const roomId = this.props.room ? this.props.room.roomId : undefined;
|
||||
|
||||
switch (this.state.phase) {
|
||||
case RightPanelPhases.RoomMemberList:
|
||||
if (roomId) {
|
||||
panel = <MemberList roomId={roomId} key={roomId} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
case RightPanelPhases.SpaceMemberList:
|
||||
panel = <MemberList
|
||||
roomId={this.state.space ? this.state.space.roomId : roomId}
|
||||
key={this.state.space ? this.state.space.roomId : roomId}
|
||||
onClose={this.onClose}
|
||||
/>;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupMemberList:
|
||||
if (this.props.groupId) {
|
||||
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupRoomList:
|
||||
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomMemberInfo:
|
||||
case RightPanelPhases.SpaceMemberInfo:
|
||||
case RightPanelPhases.EncryptionPanel:
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
room={this.state.phase === RightPanelPhases.SpaceMemberInfo ? this.state.space : this.props.room}
|
||||
key={roomId || this.state.member.userId}
|
||||
onClose={this.onClose}
|
||||
phase={this.state.phase}
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
verificationRequestPromise={this.state.verificationRequestPromise}
|
||||
/>;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Room3pidMemberInfo:
|
||||
case RightPanelPhases.Space3pidMemberInfo:
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={roomId} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupMemberInfo:
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.userId}
|
||||
phase={this.state.phase}
|
||||
onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupRoomInfo:
|
||||
panel = <GroupRoomInfo
|
||||
groupRoomId={this.state.groupRoomId}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.groupRoomId} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.NotificationPanel:
|
||||
panel = <NotificationPanel onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.PinnedMessages:
|
||||
if (SettingsStore.getValue("feature_pinning")) {
|
||||
panel = <PinnedMessagesCard room={this.props.room} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.FilePanel:
|
||||
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomSummary:
|
||||
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Widget:
|
||||
panel = <WidgetCard room={this.props.room} widgetId={this.state.widgetId} onClose={this.onClose} />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="mx_RightPanel dark-panel" id="mx_RightPanel">
|
||||
{ panel }
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher";
|
||||
import Modal from "../../Modal";
|
||||
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 160;
|
||||
|
||||
function track(action) {
|
||||
Analytics.trackEvent('RoomDirectory', action);
|
||||
}
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomDirectory',
|
||||
|
||||
propTypes: {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
protocolsLoading: true,
|
||||
error: null,
|
||||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
filterString: null,
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this.nextBatch = null;
|
||||
this.filterTimeout = null;
|
||||
this.scrollPanel = null;
|
||||
this.protocols = null;
|
||||
|
||||
this.setState({protocolsLoading: true});
|
||||
if (!MatrixClientPeg.get()) {
|
||||
// We may not have a client yet when invoked from welcome page
|
||||
this.setState({protocolsLoading: false});
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
}, (err) => {
|
||||
console.warn(`error loading thirdparty protocols: ${err}`);
|
||||
this.setState({protocolsLoading: false});
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// Guests currently aren't allowed to use this API, so
|
||||
// ignore this as otherwise this error is literally the
|
||||
// thing you see when loading the client!
|
||||
return;
|
||||
}
|
||||
track('Failed to get protocol list from homeserver');
|
||||
this.setState({
|
||||
error: _t(
|
||||
'Riot failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
this.refreshRoomList();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this._unmounted = true;
|
||||
},
|
||||
|
||||
refreshRoomList: function() {
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
});
|
||||
this.getMoreRooms();
|
||||
},
|
||||
|
||||
getMoreRooms: function() {
|
||||
if (!MatrixClientPeg.get()) return Promise.resolve();
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const my_filter_string = this.state.filterString;
|
||||
const my_server = this.state.roomServer;
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const my_next_batch = this.nextBatch;
|
||||
const opts = {limit: 20};
|
||||
if (my_server != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = my_server;
|
||||
}
|
||||
if (this.state.instanceId === ALL_ROOMS) {
|
||||
opts.include_all_networks = true;
|
||||
} else if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId;
|
||||
}
|
||||
if (this.nextBatch) opts.since = this.nextBatch;
|
||||
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
|
||||
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
|
||||
if (
|
||||
my_filter_string != this.state.filterString ||
|
||||
my_server != this.state.roomServer ||
|
||||
my_next_batch != this.nextBatch) {
|
||||
// if the filter or server has changed since this request was sent,
|
||||
// throw away the result (don't even clear the busy flag
|
||||
// since we must still have a request in flight)
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.setState((s) => {
|
||||
s.publicRooms.push(...(data.chunk || []));
|
||||
s.loading = false;
|
||||
return s;
|
||||
});
|
||||
return Boolean(data.next_batch);
|
||||
}, (err) => {
|
||||
if (
|
||||
my_filter_string != this.state.filterString ||
|
||||
my_server != this.state.roomServer ||
|
||||
my_next_batch != this.nextBatch) {
|
||||
// as above: we don't care about errors for old
|
||||
// requests either
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
|
||||
track('Failed to get public room list');
|
||||
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.')}`
|
||||
,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* A limited interface for removing rooms from the directory.
|
||||
* Will set the room to not be publicly visible and delete the
|
||||
* default alias. In the long term, it would be better to allow
|
||||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
removeFromDirectory: function(room) {
|
||||
const alias = get_display_alias_for_room(room);
|
||||
const name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
let desc;
|
||||
if (alias) {
|
||||
desc = _t('Delete the room alias %(alias)s and remove %(name)s from the directory?', {alias: alias, name: name});
|
||||
} else {
|
||||
desc = _t('Remove %(name)s from the directory?', {name: name});
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
|
||||
title: _t('Remove from Directory'),
|
||||
description: desc,
|
||||
onFinished: (should_delete) => {
|
||||
if (!should_delete) return;
|
||||
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const modal = Modal.createDialog(Loader);
|
||||
let step = _t('remove %(name)s from the directory.', {name: name});
|
||||
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
|
||||
if (!alias) return;
|
||||
step = _t('delete the alias.');
|
||||
return MatrixClientPeg.get().deleteAlias(alias);
|
||||
}).then(() => {
|
||||
modal.close();
|
||||
this.refreshRoomList();
|
||||
}, (err) => {
|
||||
modal.close();
|
||||
this.refreshRoomList();
|
||||
console.error("Failed to " + step + ": " + err);
|
||||
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onRoomClicked: function(room, ev) {
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.removeFromDirectory(room);
|
||||
} else {
|
||||
this.showRoom(room);
|
||||
}
|
||||
},
|
||||
|
||||
onOptionChange: function(server, instanceId) {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
// Clear the public rooms out here otherwise we needlessly
|
||||
// spend time filtering lots of rooms when we're about to
|
||||
// to clear the list anyway.
|
||||
publicRooms: [],
|
||||
roomServer: server,
|
||||
instanceId: instanceId,
|
||||
error: null,
|
||||
}, this.refreshRoomList);
|
||||
// We also refresh the room list each time even though this
|
||||
// filtering is client-side. It hopefully won't be client side
|
||||
// for very long, and we may have fetched a thousand rooms to
|
||||
// find the five gitter ones, at which point we do not want
|
||||
// to render all those rooms when switching back to 'all networks'.
|
||||
// Easiest to just blow away the state & re-fetch.
|
||||
},
|
||||
|
||||
onFillRequest: function(backwards) {
|
||||
if (backwards || !this.nextBatch) return Promise.resolve(false);
|
||||
|
||||
return this.getMoreRooms();
|
||||
},
|
||||
|
||||
onFilterChange: function(alias) {
|
||||
this.setState({
|
||||
filterString: alias || null,
|
||||
});
|
||||
|
||||
// don't send the request for a little bit,
|
||||
// no point hammering the server with a
|
||||
// request for every keystroke, let the
|
||||
// user finish typing.
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this.filterTimeout = setTimeout(() => {
|
||||
this.filterTimeout = null;
|
||||
this.refreshRoomList();
|
||||
}, 700);
|
||||
},
|
||||
|
||||
onFilterClear: function() {
|
||||
// update immediately
|
||||
this.setState({
|
||||
filterString: null,
|
||||
}, this.refreshRoomList);
|
||||
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
},
|
||||
|
||||
onJoinFromSearchClick: function(alias) {
|
||||
// If we don't have a particular instance id selected, just show that rooms alias
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
// If the user specified an alias without a domain, add on whichever server is selected
|
||||
// in the dropdown
|
||||
if (alias.indexOf(':') == -1) {
|
||||
alias = alias + ':' + this.state.roomServer;
|
||||
}
|
||||
this.showRoomAlias(alias, true);
|
||||
} else {
|
||||
// This is a 3rd party protocol. Let's see if we can join it
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
|
||||
if (!fields) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
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'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).then((resp) => {
|
||||
if (resp.length > 0 && resp[0].alias) {
|
||||
this.showRoomAlias(resp[0].alias, true);
|
||||
} else {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
|
||||
title: _t('Room not found'),
|
||||
description: _t('Couldn\'t find a matching Matrix room'),
|
||||
});
|
||||
}
|
||||
}, (e) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
|
||||
title: _t('Fetching third party location failed'),
|
||||
description: _t('Unable to look up room ID from server'),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onPreviewClick: function(ev, room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.room_id,
|
||||
should_peek: true,
|
||||
});
|
||||
ev.stopPropagation();
|
||||
},
|
||||
|
||||
onViewClick: function(ev, room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.room_id,
|
||||
should_peek: false,
|
||||
});
|
||||
ev.stopPropagation();
|
||||
},
|
||||
|
||||
onJoinClick: function(ev, room) {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
},
|
||||
|
||||
onCreateRoomClick: function(room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({action: 'view_create_room'});
|
||||
},
|
||||
|
||||
showRoomAlias: function(alias, autoJoin=false) {
|
||||
this.showRoom(null, alias, autoJoin);
|
||||
},
|
||||
|
||||
showRoom: function(room, room_alias, autoJoin=false) {
|
||||
this.props.onFinished();
|
||||
const payload = {
|
||||
action: 'view_room',
|
||||
auto_join: autoJoin,
|
||||
};
|
||||
if (room) {
|
||||
// Don't let the user view a room they won't be able to either
|
||||
// peek or join: fail earlier so they don't have to click back
|
||||
// to the directory.
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!room_alias) {
|
||||
room_alias = get_display_alias_for_room(room);
|
||||
}
|
||||
|
||||
payload.oob_data = {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which
|
||||
// would normally decide what the name is.
|
||||
name: room.name || room_alias || _t('Unnamed room'),
|
||||
};
|
||||
|
||||
if (this.state.roomServer) {
|
||||
payload.opts = {
|
||||
viaServers: [this.state.roomServer],
|
||||
};
|
||||
}
|
||||
}
|
||||
// It's not really possible to join Matrix rooms by ID because the HS has no way to know
|
||||
// which servers to start querying. However, there's no other way to join rooms in
|
||||
// this list without aliases at present, so if roomAlias isn't set here we have no
|
||||
// choice but to supply the ID.
|
||||
if (room_alias) {
|
||||
payload.room_alias = room_alias;
|
||||
} else {
|
||||
payload.room_id = room.room_id;
|
||||
}
|
||||
dis.dispatch(payload);
|
||||
},
|
||||
|
||||
getRow(room) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
const isGuest = client.isGuest();
|
||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
let previewButton;
|
||||
let joinOrViewButton;
|
||||
|
||||
if (room.world_readable && !hasJoinedRoom) {
|
||||
previewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
|
||||
);
|
||||
}
|
||||
if (hasJoinedRoom) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
|
||||
);
|
||||
} else if (!isGuest || room.guest_can_join) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room');
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
|
||||
}
|
||||
|
||||
let topic = room.topic || '';
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
topic = linkifyAndSanitizeHtml(topic);
|
||||
const avatarUrl = getHttpUriForMxc(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
room.avatar_url, 32, 32, "crop",
|
||||
);
|
||||
return (
|
||||
<tr key={ room.room_id }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
>
|
||||
<td className="mx_RoomDirectory_roomAvatar">
|
||||
<BaseAvatar width={32} height={32} resizeMethod='crop'
|
||||
name={ name } idName={ name }
|
||||
url={ avatarUrl } />
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomDescription">
|
||||
<div className="mx_RoomDirectory_name">{ name }</div>
|
||||
<div className="mx_RoomDirectory_topic"
|
||||
onClick={ (ev) => { ev.stopPropagation(); } }
|
||||
dangerouslySetInnerHTML={{ __html: topic }} />
|
||||
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomMemberCount">
|
||||
{ room.num_joined_members }
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_preview">{previewButton}</td>
|
||||
<td className="mx_RoomDirectory_join">{joinOrViewButton}</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
|
||||
collectScrollPanel: function(element) {
|
||||
this.scrollPanel = element;
|
||||
},
|
||||
|
||||
_stringLooksLikeId: function(s, field_type) {
|
||||
let pat = /^#[^\s]+:[^\s]/;
|
||||
if (field_type && field_type.regexp) {
|
||||
pat = new RegExp(field_type.regexp);
|
||||
}
|
||||
|
||||
return pat.test(s);
|
||||
},
|
||||
|
||||
_getFieldsForThirdPartyLocation: function(userInput, protocol, instance) {
|
||||
// make an object with the fields specified by that protocol. We
|
||||
// require that the values of all but the last field come from the
|
||||
// instance. The last is the user input.
|
||||
const requiredFields = protocol.location_fields;
|
||||
if (!requiredFields) return null;
|
||||
const fields = {};
|
||||
for (let i = 0; i < requiredFields.length - 1; ++i) {
|
||||
const thisField = requiredFields[i];
|
||||
if (instance.fields[thisField] === undefined) return null;
|
||||
fields[thisField] = instance.fields[thisField];
|
||||
}
|
||||
fields[requiredFields[requiredFields.length - 1]] = userInput;
|
||||
return fields;
|
||||
},
|
||||
|
||||
/**
|
||||
* called by the parent component when PageUp/Down/etc is pressed.
|
||||
*
|
||||
* We pass it down to the scroll panel.
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
if (this.scrollPanel) {
|
||||
this.scrollPanel.handleScrollKey(ev);
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let content;
|
||||
if (this.state.error) {
|
||||
content = this.state.error;
|
||||
} else if (this.state.protocolsLoading) {
|
||||
content = <Loader />;
|
||||
} else {
|
||||
const rows = (this.state.publicRooms || []).map(room => this.getRow(room));
|
||||
// we still show the scrollpanel, at least for now, because
|
||||
// otherwise we don't fetch more because we don't get a fill
|
||||
// request from the scrollpanel because there isn't one
|
||||
|
||||
let spinner;
|
||||
if (this.state.loading) {
|
||||
spinner = <Loader />;
|
||||
}
|
||||
|
||||
let scrollpanel_content;
|
||||
if (rows.length === 0 && !this.state.loading) {
|
||||
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
|
||||
} else {
|
||||
scrollpanel_content = <table className="mx_RoomDirectory_table">
|
||||
<tbody>
|
||||
{ rows }
|
||||
</tbody>
|
||||
</table>;
|
||||
}
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
content = <ScrollPanel ref={this.collectScrollPanel}
|
||||
className="mx_RoomDirectory_tableWrapper"
|
||||
onFillRequest={ this.onFillRequest }
|
||||
stickyBottom={false}
|
||||
startAtBottom={false}
|
||||
>
|
||||
{ scrollpanel_content }
|
||||
{ spinner }
|
||||
</ScrollPanel>;
|
||||
}
|
||||
|
||||
let listHeader;
|
||||
if (!this.state.protocolsLoading) {
|
||||
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
|
||||
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
|
||||
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
let instance_expected_field_type;
|
||||
if (
|
||||
protocolName &&
|
||||
this.protocols &&
|
||||
this.protocols[protocolName] &&
|
||||
this.protocols[protocolName].location_fields.length > 0 &&
|
||||
this.protocols[protocolName].field_types
|
||||
) {
|
||||
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
|
||||
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
|
||||
}
|
||||
|
||||
let placeholder = _t('Find a room…');
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
|
||||
} else if (instance_expected_field_type) {
|
||||
placeholder = instance_expected_field_type.placeholder;
|
||||
}
|
||||
|
||||
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
|
||||
showJoinButton = false;
|
||||
}
|
||||
}
|
||||
|
||||
listHeader = <div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
onChange={this.onFilterChange}
|
||||
onClear={this.onFilterClear}
|
||||
onJoinClick={this.onJoinFromSearchClick}
|
||||
placeholder={placeholder}
|
||||
showJoinButton={showJoinButton}
|
||||
/>
|
||||
<NetworkDropdown
|
||||
protocols={this.protocols}
|
||||
onOptionChange={this.onOptionChange}
|
||||
selectedServerName={this.state.roomServer}
|
||||
selectedInstanceId={this.state.instanceId}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
|
||||
{a: sub => {
|
||||
return (<AccessibleButton
|
||||
kind="secondary"
|
||||
onClick={this.onCreateRoomClick}
|
||||
>{sub}</AccessibleButton>);
|
||||
}},
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Explore rooms")}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
{explanation}
|
||||
<div className="mx_RoomDirectory_list">
|
||||
{listHeader}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function get_display_alias_for_room(room) {
|
||||
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
|
||||
}
|
||||
838
src/components/structures/RoomDirectory.tsx
Normal file
838
src/components/structures/RoomDirectory.tsx
Normal file
@@ -0,0 +1,838 @@
|
||||
/*
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
|
||||
import { Visibility } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import Modal from "../../Modal";
|
||||
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import GroupStore from "../../stores/GroupStore";
|
||||
import FlairStore from "../../stores/FlairStore";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { IDialogProps } from "../views/dialogs/IDialogProps";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
||||
const LAST_SERVER_KEY = "mx_last_room_directory_server";
|
||||
const LAST_INSTANCE_KEY = "mx_last_room_directory_instance";
|
||||
|
||||
function track(action: string) {
|
||||
Analytics.trackEvent('RoomDirectory', action);
|
||||
}
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
publicRooms: IPublicRoomsChunkRoom[];
|
||||
loading: boolean;
|
||||
protocolsLoading: boolean;
|
||||
error?: string;
|
||||
instanceId: string;
|
||||
roomServer: string;
|
||||
filterString: string;
|
||||
selectedCommunityId?: string;
|
||||
communityName?: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.RoomDirectory")
|
||||
export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
private readonly startTime: number;
|
||||
private unmounted = false;
|
||||
private nextBatch: string = null;
|
||||
private filterTimeout: number;
|
||||
private protocols: Protocols;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.trackRoomDirectoryBegin();
|
||||
this.startTime = CountlyAnalytics.getTimestamp();
|
||||
|
||||
const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes")
|
||||
? GroupFilterOrderStore.getSelectedTags()[0]
|
||||
: null;
|
||||
|
||||
let protocolsLoading = true;
|
||||
if (!MatrixClientPeg.get()) {
|
||||
// We may not have a client yet when invoked from welcome page
|
||||
protocolsLoading = false;
|
||||
} else if (!selectedCommunityId) {
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
const myHomeserver = MatrixClientPeg.getHomeserverName();
|
||||
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
|
||||
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
|
||||
|
||||
let roomServer = myHomeserver;
|
||||
if (
|
||||
SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) ||
|
||||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
|
||||
) {
|
||||
roomServer = lsRoomServer;
|
||||
}
|
||||
|
||||
let instanceId: string = null;
|
||||
if (roomServer === myHomeserver && (
|
||||
lsInstanceId === ALL_ROOMS ||
|
||||
Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
|
||||
)) {
|
||||
instanceId = lsInstanceId;
|
||||
}
|
||||
|
||||
// Refresh the room list only if validation failed and we had to change these
|
||||
if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) {
|
||||
this.setState({
|
||||
protocolsLoading: false,
|
||||
instanceId,
|
||||
roomServer,
|
||||
});
|
||||
this.refreshRoomList();
|
||||
return;
|
||||
}
|
||||
this.setState({ protocolsLoading: false });
|
||||
}, (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
|
||||
// ignore this as otherwise this error is literally the
|
||||
// thing you see when loading the client!
|
||||
return;
|
||||
}
|
||||
track('Failed to get protocol list from homeserver');
|
||||
const brand = SdkConfig.get().brand;
|
||||
this.setState({
|
||||
error: _t(
|
||||
'%(brand)s failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
{ brand },
|
||||
),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// We don't use the protocols in the communities v2 prototype experience
|
||||
protocolsLoading = false;
|
||||
|
||||
// Grab the profile info async
|
||||
FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => {
|
||||
this.setState({ communityName: profile.name });
|
||||
});
|
||||
}
|
||||
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
|
||||
roomServer: localStorage.getItem(LAST_SERVER_KEY),
|
||||
filterString: this.props.initialText || "",
|
||||
selectedCommunityId,
|
||||
communityName: null,
|
||||
protocolsLoading,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.refreshRoomList();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
private refreshRoomList = () => {
|
||||
if (this.state.selectedCommunityId) {
|
||||
this.setState({
|
||||
publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => {
|
||||
return {
|
||||
// Translate all the group properties to the directory format
|
||||
room_id: r.roomId,
|
||||
name: r.name,
|
||||
topic: r.topic,
|
||||
canonical_alias: r.canonicalAlias,
|
||||
num_joined_members: r.numJoinedMembers,
|
||||
avatarUrl: r.avatarUrl,
|
||||
world_readable: r.worldReadable,
|
||||
guest_can_join: r.guestsCanJoin,
|
||||
};
|
||||
}).filter(r => {
|
||||
const filterString = this.state.filterString;
|
||||
if (filterString) {
|
||||
const containedIn = (s: string) => (s || "").toLowerCase().includes(filterString.toLowerCase());
|
||||
return containedIn(r.name) || containedIn(r.topic) || containedIn(r.canonical_alias);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
});
|
||||
this.getMoreRooms();
|
||||
};
|
||||
|
||||
private getMoreRooms(): Promise<boolean> {
|
||||
if (this.state.selectedCommunityId) return Promise.resolve(false); // no more rooms
|
||||
if (!MatrixClientPeg.get()) return Promise.resolve(false);
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const filterString = this.state.filterString;
|
||||
const roomServer = this.state.roomServer;
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const nextBatch = this.nextBatch;
|
||||
const opts: IRoomDirectoryOptions = { limit: 20 };
|
||||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
}
|
||||
if (this.state.instanceId === ALL_ROOMS) {
|
||||
opts.include_all_networks = true;
|
||||
} else if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId as string;
|
||||
}
|
||||
if (this.nextBatch) opts.since = this.nextBatch;
|
||||
if (filterString) opts.filter = { generic_search_term: filterString };
|
||||
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
|
||||
if (
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// if the filter or server has changed since this request was sent,
|
||||
// throw away the result (don't even clear the busy flag
|
||||
// since we must still have a request in flight)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.state.filterString) {
|
||||
const count = data.total_room_count_estimate || data.chunk.length;
|
||||
CountlyAnalytics.instance.trackRoomDirectorySearch(count, this.state.filterString);
|
||||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.setState((s) => ({
|
||||
...s,
|
||||
publicRooms: [...s.publicRooms, ...(data.chunk || [])],
|
||||
loading: false,
|
||||
}));
|
||||
return Boolean(data.next_batch);
|
||||
}, (err) => {
|
||||
if (
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// as above: we don't care about errors for old requests either
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return false;
|
||||
}
|
||||
|
||||
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('%(brand)s failed to get the public room list.', { brand }) +
|
||||
(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A limited interface for removing rooms from the directory.
|
||||
* Will set the room to not be publicly visible and delete the
|
||||
* default alias. In the long term, it would be better to allow
|
||||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
private removeFromDirectory(room: IPublicRoomsChunkRoom) {
|
||||
const alias = getDisplayAliasForRoom(room);
|
||||
const name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
let desc;
|
||||
if (alias) {
|
||||
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', { alias, name });
|
||||
} else {
|
||||
desc = _t('Remove %(name)s from the directory?', { name: name });
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
|
||||
title: _t('Remove from Directory'),
|
||||
description: desc,
|
||||
onFinished: (shouldDelete: boolean) => {
|
||||
if (!shouldDelete) return;
|
||||
|
||||
const modal = Modal.createDialog(Spinner);
|
||||
let step = _t('remove %(name)s from the directory.', { name: name });
|
||||
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => {
|
||||
if (!alias) return;
|
||||
step = _t('delete the address.');
|
||||
return MatrixClientPeg.get().deleteAlias(alias);
|
||||
}).then(() => {
|
||||
modal.close();
|
||||
this.refreshRoomList();
|
||||
}, (err) => {
|
||||
modal.close();
|
||||
this.refreshRoomList();
|
||||
console.error("Failed to " + step + ": " + err);
|
||||
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: (err && err.message)
|
||||
? err.message
|
||||
: _t('The server may be unavailable or overloaded'),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
|
||||
// If room was shift-clicked, remove it from the room directory
|
||||
if (ev.shiftKey && !this.state.selectedCommunityId) {
|
||||
ev.preventDefault();
|
||||
this.removeFromDirectory(room);
|
||||
}
|
||||
};
|
||||
|
||||
private onOptionChange = (server: string, instanceId?: string) => {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
// Clear the public rooms out here otherwise we needlessly
|
||||
// spend time filtering lots of rooms when we're about to
|
||||
// to clear the list anyway.
|
||||
publicRooms: [],
|
||||
roomServer: server,
|
||||
instanceId: instanceId,
|
||||
error: null,
|
||||
}, this.refreshRoomList);
|
||||
// We also refresh the room list each time even though this
|
||||
// filtering is client-side. It hopefully won't be client side
|
||||
// for very long, and we may have fetched a thousand rooms to
|
||||
// find the five gitter ones, at which point we do not want
|
||||
// to render all those rooms when switching back to 'all networks'.
|
||||
// Easiest to just blow away the state & re-fetch.
|
||||
|
||||
// We have to be careful here so that we don't set instanceId = "undefined"
|
||||
localStorage.setItem(LAST_SERVER_KEY, server);
|
||||
if (instanceId) {
|
||||
localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
|
||||
} else {
|
||||
localStorage.removeItem(LAST_INSTANCE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
private onFillRequest = (backwards: boolean) => {
|
||||
if (backwards || !this.nextBatch) return Promise.resolve(false);
|
||||
|
||||
return this.getMoreRooms();
|
||||
};
|
||||
|
||||
private onFilterChange = (alias: string) => {
|
||||
this.setState({
|
||||
filterString: alias || "",
|
||||
});
|
||||
|
||||
// don't send the request for a little bit,
|
||||
// no point hammering the server with a
|
||||
// request for every keystroke, let the
|
||||
// user finish typing.
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this.filterTimeout = setTimeout(() => {
|
||||
this.filterTimeout = null;
|
||||
this.refreshRoomList();
|
||||
}, 700);
|
||||
};
|
||||
|
||||
private onFilterClear = () => {
|
||||
// update immediately
|
||||
this.setState({
|
||||
filterString: "",
|
||||
}, this.refreshRoomList);
|
||||
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
private onJoinFromSearchClick = (alias: string) => {
|
||||
// If we don't have a particular instance id selected, just show that rooms alias
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
// If the user specified an alias without a domain, add on whichever server is selected
|
||||
// in the dropdown
|
||||
if (alias.indexOf(':') == -1) {
|
||||
alias = alias + ':' + this.state.roomServer;
|
||||
}
|
||||
this.showRoomAlias(alias, true);
|
||||
} else {
|
||||
// This is a 3rd party protocol. Let's see if we can join it
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
const fields = protocolName
|
||||
? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance)
|
||||
: null;
|
||||
if (!fields) {
|
||||
const brand = SdkConfig.get().brand;
|
||||
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
|
||||
title: _t('Unable to join network'),
|
||||
description: _t('%(brand)s does not know how to join a room on this network', { brand }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).then((resp) => {
|
||||
if (resp.length > 0 && resp[0].alias) {
|
||||
this.showRoomAlias(resp[0].alias, true);
|
||||
} else {
|
||||
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
|
||||
title: _t('Room not found'),
|
||||
description: _t('Couldn\'t find a matching Matrix room'),
|
||||
});
|
||||
}
|
||||
}, (e) => {
|
||||
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
|
||||
title: _t('Fetching third party location failed'),
|
||||
description: _t('Unable to look up room ID from server'),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, false, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onCreateRoomClick = () => {
|
||||
this.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
defaultName: this.state.filterString.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
private showRoomAlias(alias: string, autoJoin = false) {
|
||||
this.showRoom(null, alias, autoJoin);
|
||||
}
|
||||
|
||||
private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
this.onFinished();
|
||||
const payload: ActionPayload = {
|
||||
action: 'view_room',
|
||||
auto_join: autoJoin,
|
||||
should_peek: shouldPeek,
|
||||
_type: "room_directory", // instrumentation
|
||||
};
|
||||
if (room) {
|
||||
// Don't let the user view a room they won't be able to either
|
||||
// peek or join: fail earlier so they don't have to click back
|
||||
// to the directory.
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({ action: 'require_registration' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!roomAlias) {
|
||||
roomAlias = getDisplayAliasForRoom(room);
|
||||
}
|
||||
|
||||
payload.oob_data = {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which
|
||||
// would normally decide what the name is.
|
||||
name: room.name || roomAlias || _t('Unnamed room'),
|
||||
};
|
||||
|
||||
if (this.state.roomServer) {
|
||||
payload.via_servers = [this.state.roomServer];
|
||||
payload.opts = {
|
||||
viaServers: [this.state.roomServer],
|
||||
};
|
||||
}
|
||||
}
|
||||
// It's not really possible to join Matrix rooms by ID because the HS has no way to know
|
||||
// which servers to start querying. However, there's no other way to join rooms in
|
||||
// this list without aliases at present, so if roomAlias isn't set here we have no
|
||||
// choice but to supply the ID.
|
||||
if (roomAlias) {
|
||||
payload.room_alias = roomAlias;
|
||||
} else {
|
||||
payload.room_id = room.room_id;
|
||||
}
|
||||
dis.dispatch(payload);
|
||||
}
|
||||
|
||||
private createRoomCells(room: IPublicRoomsChunkRoom) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
const isGuest = client.isGuest();
|
||||
let previewButton;
|
||||
let joinOrViewButton;
|
||||
|
||||
// Element Web currently does not allow guests to join rooms, so we
|
||||
// instead show them preview buttons for all rooms. If the room is not
|
||||
// world readable, a modal will appear asking you to register first. If
|
||||
// it is readable, the preview appears as normal.
|
||||
if (!hasJoinedRoom && (room.world_readable || isGuest)) {
|
||||
previewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>
|
||||
{ _t("Preview") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
if (hasJoinedRoom) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>
|
||||
{ _t("View") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (!isGuest) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
|
||||
}
|
||||
|
||||
let topic = room.topic || '';
|
||||
// Additional truncation based on line numbers is done via CSS,
|
||||
// but to ensure that the DOM is not polluted with a huge string
|
||||
// we give it a hard limit before rendering.
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
topic = linkifyAndSanitizeHtml(topic);
|
||||
let avatarUrl = null;
|
||||
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
|
||||
|
||||
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
|
||||
return [
|
||||
<div
|
||||
key={ `${room.room_id}_avatar` }
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomAvatar"
|
||||
>
|
||||
<BaseAvatar
|
||||
width={32}
|
||||
height={32}
|
||||
resizeMethod='crop'
|
||||
name={name}
|
||||
idName={name}
|
||||
url={avatarUrl}
|
||||
/>
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_description` }
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomDescription"
|
||||
>
|
||||
<div
|
||||
className="mx_RoomDirectory_name"
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
>
|
||||
{ name }
|
||||
</div>
|
||||
<div
|
||||
className="mx_RoomDirectory_topic"
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
dangerouslySetInnerHTML={{ __html: topic }}
|
||||
/>
|
||||
<div
|
||||
className="mx_RoomDirectory_alias"
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
>
|
||||
{ getDisplayAliasForRoom(room) }
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_memberCount` }
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomMemberCount"
|
||||
>
|
||||
{ room.num_joined_members }
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_preview` }
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
className="mx_RoomDirectory_preview"
|
||||
>
|
||||
{ previewButton }
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_join` }
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_join"
|
||||
>
|
||||
{ joinOrViewButton }
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
private stringLooksLikeId(s: string, fieldType: IFieldType) {
|
||||
let pat = /^#[^\s]+:[^\s]/;
|
||||
if (fieldType && fieldType.regexp) {
|
||||
pat = new RegExp(fieldType.regexp);
|
||||
}
|
||||
|
||||
return pat.test(s);
|
||||
}
|
||||
|
||||
private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) {
|
||||
// make an object with the fields specified by that protocol. We
|
||||
// require that the values of all but the last field come from the
|
||||
// instance. The last is the user input.
|
||||
const requiredFields = protocol.location_fields;
|
||||
if (!requiredFields) return null;
|
||||
const fields = {};
|
||||
for (let i = 0; i < requiredFields.length - 1; ++i) {
|
||||
const thisField = requiredFields[i];
|
||||
if (instance.fields[thisField] === undefined) return null;
|
||||
fields[thisField] = instance.fields[thisField];
|
||||
}
|
||||
fields[requiredFields[requiredFields.length - 1]] = userInput;
|
||||
return fields;
|
||||
}
|
||||
|
||||
private onFinished = () => {
|
||||
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
render() {
|
||||
let content;
|
||||
if (this.state.error) {
|
||||
content = this.state.error;
|
||||
} else if (this.state.protocolsLoading) {
|
||||
content = <Spinner />;
|
||||
} else {
|
||||
const cells = (this.state.publicRooms || [])
|
||||
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), []);
|
||||
// we still show the scrollpanel, at least for now, because
|
||||
// otherwise we don't fetch more because we don't get a fill
|
||||
// request from the scrollpanel because there isn't one
|
||||
|
||||
let spinner;
|
||||
if (this.state.loading) {
|
||||
spinner = <Spinner />;
|
||||
}
|
||||
|
||||
const createNewButton = <>
|
||||
<hr />
|
||||
<AccessibleButton kind="primary" onClick={this.onCreateRoomClick} className="mx_RoomDirectory_newRoom">
|
||||
{ _t("Create new room") }
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
|
||||
let scrollPanelContent;
|
||||
let footer;
|
||||
if (cells.length === 0 && !this.state.loading) {
|
||||
footer = <>
|
||||
<h5>{ _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }</h5>
|
||||
<p>
|
||||
{ _t("Try different words or check for typos. " +
|
||||
"Some results may not be visible as they're private and you need an invite to join them.") }
|
||||
</p>
|
||||
{ createNewButton }
|
||||
</>;
|
||||
} else {
|
||||
scrollPanelContent = <div className="mx_RoomDirectory_table">
|
||||
{ cells }
|
||||
</div>;
|
||||
if (!this.state.loading && !this.nextBatch) {
|
||||
footer = createNewButton;
|
||||
}
|
||||
}
|
||||
content = <ScrollPanel
|
||||
className="mx_RoomDirectory_tableWrapper"
|
||||
onFillRequest={this.onFillRequest}
|
||||
stickyBottom={false}
|
||||
startAtBottom={false}
|
||||
>
|
||||
{ scrollPanelContent }
|
||||
{ spinner }
|
||||
{ footer && <div className="mx_RoomDirectory_footer">
|
||||
{ footer }
|
||||
</div> }
|
||||
</ScrollPanel>;
|
||||
}
|
||||
|
||||
let listHeader;
|
||||
if (!this.state.protocolsLoading) {
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
let instanceExpectedFieldType;
|
||||
if (
|
||||
protocolName &&
|
||||
this.protocols &&
|
||||
this.protocols[protocolName] &&
|
||||
this.protocols[protocolName].location_fields.length > 0 &&
|
||||
this.protocols[protocolName].field_types
|
||||
) {
|
||||
const lastField = this.protocols[protocolName].location_fields.slice(-1)[0];
|
||||
instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField];
|
||||
}
|
||||
|
||||
let placeholder = _t('Find a room…');
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
|
||||
exampleRoom: "#example:" + this.state.roomServer,
|
||||
});
|
||||
} else if (instanceExpectedFieldType) {
|
||||
placeholder = instanceExpectedFieldType.placeholder;
|
||||
}
|
||||
|
||||
let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (this.getFieldsForThirdPartyLocation(
|
||||
this.state.filterString,
|
||||
this.protocols[protocolName],
|
||||
instance,
|
||||
) === null) {
|
||||
showJoinButton = false;
|
||||
}
|
||||
}
|
||||
|
||||
let dropdown = (
|
||||
<NetworkDropdown
|
||||
protocols={this.protocols}
|
||||
onOptionChange={this.onOptionChange}
|
||||
selectedServerName={this.state.roomServer}
|
||||
selectedInstanceId={this.state.instanceId}
|
||||
/>
|
||||
);
|
||||
if (this.state.selectedCommunityId) {
|
||||
dropdown = null;
|
||||
}
|
||||
|
||||
listHeader = <div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
onChange={this.onFilterChange}
|
||||
onClear={this.onFilterClear}
|
||||
onJoinClick={this.onJoinFromSearchClick}
|
||||
placeholder={placeholder}
|
||||
showJoinButton={showJoinButton}
|
||||
initialText={this.props.initialText}
|
||||
/>
|
||||
{dropdown}
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
|
||||
{ a: sub => (
|
||||
<AccessibleButton kind="secondary" onClick={this.onCreateRoomClick}>
|
||||
{ sub }
|
||||
</AccessibleButton>
|
||||
) },
|
||||
);
|
||||
|
||||
const title = this.state.selectedCommunityId
|
||||
? _t("Explore rooms in %(communityName)s", {
|
||||
communityName: this.state.communityName || this.state.selectedCommunityId,
|
||||
}) : _t("Explore rooms");
|
||||
return (
|
||||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
hasCancel={true}
|
||||
onFinished={this.onFinished}
|
||||
title={title}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
{explanation}
|
||||
<div className="mx_RoomDirectory_list">
|
||||
{listHeader}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
218
src/components/structures/RoomSearch.tsx
Normal file
218
src/components/structures/RoomSearch.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
onKeyDown(ev: React.KeyboardEvent): void;
|
||||
/**
|
||||
* @returns true if a room has been selected and the search field should be cleared
|
||||
*/
|
||||
onSelectRoom(): boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
query: string;
|
||||
focused: boolean;
|
||||
inSpaces: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.RoomSearch")
|
||||
export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private inputRef: React.RefObject<HTMLInputElement> = createRef();
|
||||
private searchFilter: NameFilterCondition = new NameFilterCondition();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
query: "",
|
||||
focused: false,
|
||||
inSpaces: false,
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
// clear filter when changing spaces, in future we may wish to maintain a filter per-space
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
|
||||
SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
|
||||
if (prevState.query !== this.state.query) {
|
||||
const hadSearch = !!this.searchFilter.search.trim();
|
||||
const haveSearch = !!this.state.query.trim();
|
||||
this.searchFilter.search = this.state.query;
|
||||
if (!hadSearch && haveSearch) {
|
||||
// started a new filter - add the condition
|
||||
RoomListStore.instance.addFilter(this.searchFilter);
|
||||
} else if (hadSearch && !haveSearch) {
|
||||
// cleared a filter - remove the condition
|
||||
RoomListStore.instance.removeFilter(this.searchFilter);
|
||||
} // else the filter hasn't changed enough for us to care here
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
|
||||
SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
|
||||
}
|
||||
|
||||
private onSpaces = (spaces: Room[]) => {
|
||||
this.setState({
|
||||
inSpaces: spaces.length > 0,
|
||||
});
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === 'view_room' && payload.clear_search) {
|
||||
this.clearInput();
|
||||
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private clearInput = () => {
|
||||
if (!this.inputRef.current) return;
|
||||
this.inputRef.current.value = "";
|
||||
this.onChange();
|
||||
};
|
||||
|
||||
private openSearch = () => {
|
||||
defaultDispatcher.dispatch({ action: "show_left_panel" });
|
||||
defaultDispatcher.dispatch({ action: "focus_room_filter" });
|
||||
};
|
||||
|
||||
private onChange = () => {
|
||||
if (!this.inputRef.current) return;
|
||||
this.setState({ query: this.inputRef.current.value });
|
||||
};
|
||||
|
||||
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
this.setState({ focused: true });
|
||||
ev.target.select();
|
||||
};
|
||||
|
||||
private onBlur = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
this.setState({ focused: false });
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
const action = getKeyBindingsManager().getRoomListAction(ev);
|
||||
switch (action) {
|
||||
case RoomListAction.ClearSearch:
|
||||
this.clearInput();
|
||||
defaultDispatcher.fire(Action.FocusSendMessageComposer);
|
||||
break;
|
||||
case RoomListAction.NextRoom:
|
||||
case RoomListAction.PrevRoom:
|
||||
// we don't handle these actions here put pass the event on to the interested party (LeftPanel)
|
||||
this.props.onKeyDown(ev);
|
||||
break;
|
||||
case RoomListAction.SelectRoom: {
|
||||
const shouldClear = this.props.onSelectRoom();
|
||||
if (shouldClear) {
|
||||
// wrap in set immediate to delay it so that we don't clear the filter & then change room
|
||||
setImmediate(() => {
|
||||
this.clearInput();
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const classes = classNames({
|
||||
'mx_RoomSearch': true,
|
||||
'mx_RoomSearch_hasQuery': this.state.query,
|
||||
'mx_RoomSearch_focused': this.state.focused,
|
||||
'mx_RoomSearch_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const inputClasses = classNames({
|
||||
'mx_RoomSearch_input': true,
|
||||
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
|
||||
});
|
||||
|
||||
let placeholder = _t("Filter");
|
||||
if (this.state.inSpaces) {
|
||||
placeholder = _t("Filter all spaces");
|
||||
}
|
||||
|
||||
let icon = (
|
||||
<div className='mx_RoomSearch_icon' />
|
||||
);
|
||||
let input = (
|
||||
<input
|
||||
type="text"
|
||||
ref={this.inputRef}
|
||||
className={inputClasses}
|
||||
value={this.state.query}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
let clearButton = (
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
title={_t("Clear filter")}
|
||||
className="mx_RoomSearch_clearButton"
|
||||
onClick={this.clearInput}
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.props.isMinimized) {
|
||||
icon = (
|
||||
<AccessibleButton
|
||||
title={_t("Filter rooms and people")}
|
||||
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
|
||||
onClick={this.openSearch}
|
||||
/>
|
||||
);
|
||||
input = null;
|
||||
clearButton = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{icon}
|
||||
{input}
|
||||
{clearButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -17,42 +15,36 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
import * as sdk from '../../index';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import Resend from '../../Resend';
|
||||
import * as cryptodevices from '../../cryptodevices';
|
||||
import dis from '../../dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { EventStatus } from "matrix-js-sdk/src/models/event";
|
||||
import NotificationBadge from "../views/rooms/NotificationBadge";
|
||||
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import InlineSpinner from "../views/elements/InlineSpinner";
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
const STATUS_BAR_EXPANDED_LARGE = 2;
|
||||
|
||||
function getUnsentMessages(room) {
|
||||
export function getUnsentMessages(room) {
|
||||
if (!room) { return []; }
|
||||
return room.getPendingEvents().filter(function(ev) {
|
||||
return ev.status === Matrix.EventStatus.NOT_SENT;
|
||||
return ev.status === EventStatus.NOT_SENT;
|
||||
});
|
||||
}
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomStatusBar',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("structures.RoomStatusBar")
|
||||
export default class RoomStatusBar extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// the room this statusbar is representing.
|
||||
room: PropTypes.object.isRequired,
|
||||
// This is true when the user is alone in the room, but has also sent a message.
|
||||
// Used to suggest to the user to invite someone
|
||||
sentMessageAndIsAlone: PropTypes.bool,
|
||||
|
||||
// true if there is an active call in this room (means we show
|
||||
// the 'Active Call' text in the status bar if there is nothing
|
||||
// more interesting)
|
||||
hasActiveCall: PropTypes.bool,
|
||||
|
||||
// true if the room is being peeked at. This affects components that shouldn't
|
||||
// logically be shown when peeking, such as a prompt to invite people to a room.
|
||||
@@ -70,10 +62,6 @@ export default createReactClass({
|
||||
// 'you are alone' bar
|
||||
onInviteClick: PropTypes.func,
|
||||
|
||||
// callback for when the user clicks on the 'stop warning me' button in the
|
||||
// 'you are alone' bar
|
||||
onStopWarningClick: PropTypes.func,
|
||||
|
||||
// callback for when we do something that changes the size of the
|
||||
// status bar. This is used to trigger a re-layout in the parent
|
||||
// component.
|
||||
@@ -86,37 +74,36 @@ export default createReactClass({
|
||||
// callback for when the status bar is displaying something and should
|
||||
// be visible
|
||||
onVisible: PropTypes.func,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
syncStateData: MatrixClientPeg.get().getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
};
|
||||
},
|
||||
state = {
|
||||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
syncStateData: MatrixClientPeg.get().getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
isResending: false,
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
|
||||
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
|
||||
|
||||
this._checkSize();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidUpdate: function() {
|
||||
componentDidUpdate() {
|
||||
this._checkSize();
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) {
|
||||
client.removeListener("sync", this.onSyncStateChange);
|
||||
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onSyncStateChange: function(state, prevState, data) {
|
||||
onSyncStateChange = (state, prevState, data) => {
|
||||
if (state === "SYNCING" && prevState === "SYNCING") {
|
||||
return;
|
||||
}
|
||||
@@ -124,78 +111,52 @@ export default createReactClass({
|
||||
syncState: state,
|
||||
syncStateData: data,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onSendWithoutVerifyingClick: function() {
|
||||
cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => {
|
||||
cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices);
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
_onResendAllClick = () => {
|
||||
Resend.resendUnsentEvents(this.props.room).then(() => {
|
||||
this.setState({ isResending: false });
|
||||
});
|
||||
},
|
||||
this.setState({ isResending: true });
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
};
|
||||
|
||||
_onResendAllClick: function() {
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
},
|
||||
|
||||
_onCancelAllClick: function() {
|
||||
_onCancelAllClick = () => {
|
||||
Resend.cancelUnsentEvents(this.props.room);
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
},
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
};
|
||||
|
||||
_onShowDevicesClick: function() {
|
||||
cryptodevices.showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
|
||||
},
|
||||
|
||||
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
|
||||
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
|
||||
if (room.roomId !== this.props.room.roomId) return;
|
||||
|
||||
const messages = getUnsentMessages(this.props.room);
|
||||
this.setState({
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
unsentMessages: messages,
|
||||
isResending: messages.length > 0 && this.state.isResending,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Check whether current size is greater than 0, if yes call props.onVisible
|
||||
_checkSize: function() {
|
||||
_checkSize() {
|
||||
if (this._getSize()) {
|
||||
if (this.props.onVisible) this.props.onVisible();
|
||||
} else {
|
||||
if (this.props.onHidden) this.props.onHidden();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// We don't need the actual height - just whether it is likely to have
|
||||
// changed - so we use '0' to indicate normal size, and other values to
|
||||
// indicate other sizes.
|
||||
_getSize: function() {
|
||||
if (this._shouldShowConnectionError() ||
|
||||
this.props.hasActiveCall ||
|
||||
this.props.sentMessageAndIsAlone
|
||||
) {
|
||||
_getSize() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return STATUS_BAR_EXPANDED;
|
||||
} else if (this.state.unsentMessages.length > 0) {
|
||||
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return STATUS_BAR_EXPANDED_LARGE;
|
||||
}
|
||||
return STATUS_BAR_HIDDEN;
|
||||
},
|
||||
}
|
||||
|
||||
// return suitable content for the image on the left of the status bar.
|
||||
_getIndicator: function() {
|
||||
if (this.props.hasActiveCall) {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
return (
|
||||
<TintableSvg src={require("../../../res/img/sound-indicator.svg")} width="23" height="20" />
|
||||
);
|
||||
}
|
||||
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
_shouldShowConnectionError: function() {
|
||||
_shouldShowConnectionError() {
|
||||
// no conn bar trumps the "some not sent" msg since you can't resend without
|
||||
// a connection!
|
||||
// There's one situation in which we don't show this 'no connection' bar, and that's
|
||||
@@ -206,166 +167,125 @@ export default createReactClass({
|
||||
this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED',
|
||||
);
|
||||
return this.state.syncState === "ERROR" && !errorIsMauError;
|
||||
},
|
||||
}
|
||||
|
||||
_getUnsentMessageContent: function() {
|
||||
_getUnsentMessageContent() {
|
||||
const unsentMessages = this.state.unsentMessages;
|
||||
if (!unsentMessages.length) return null;
|
||||
|
||||
let title;
|
||||
let content;
|
||||
|
||||
const hasUDE = unsentMessages.some((m) => {
|
||||
return m.error && m.error.name === "UnknownDeviceError";
|
||||
});
|
||||
|
||||
if (hasUDE) {
|
||||
title = _t("Message not sent due to unknown sessions being present");
|
||||
content = _t(
|
||||
"<showSessionsText>Show sessions</showSessionsText>, <sendAnywayText>send anyway</sendAnywayText> or <cancelText>cancel</cancelText>.",
|
||||
let consentError = null;
|
||||
let resourceLimitError = null;
|
||||
for (const m of unsentMessages) {
|
||||
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
|
||||
consentError = m.error;
|
||||
break;
|
||||
} else if (m.error && m.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
resourceLimitError = m.error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (consentError) {
|
||||
title = _t(
|
||||
"You can't send any messages until you review and agree to " +
|
||||
"<consentLink>our terms and conditions</consentLink>.",
|
||||
{},
|
||||
{
|
||||
'showSessionsText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
|
||||
'sendAnywayText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="sendAnyway" onClick={this._onSendWithoutVerifyingClick}>{ sub }</a>,
|
||||
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
|
||||
'consentLink': (sub) =>
|
||||
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let consentError = null;
|
||||
let resourceLimitError = null;
|
||||
for (const m of unsentMessages) {
|
||||
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
|
||||
consentError = m.error;
|
||||
break;
|
||||
} else if (m.error && m.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
resourceLimitError = m.error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (consentError) {
|
||||
title = _t(
|
||||
"You can't send any messages until you review and agree to " +
|
||||
"<consentLink>our terms and conditions</consentLink>.",
|
||||
{},
|
||||
{
|
||||
'consentLink': (sub) =>
|
||||
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
);
|
||||
} else if (resourceLimitError) {
|
||||
title = messageForResourceLimitError(
|
||||
resourceLimitError.data.limit_type,
|
||||
resourceLimitError.data.admin_contact, {
|
||||
} else if (resourceLimitError) {
|
||||
title = messageForResourceLimitError(
|
||||
resourceLimitError.data.limit_type,
|
||||
resourceLimitError.data.admin_contact,
|
||||
{
|
||||
'monthly_active_user': _td(
|
||||
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'hs_disabled': _td(
|
||||
"Your message wasn't sent because this homeserver has been blocked by it's administrator. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'': _td(
|
||||
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
});
|
||||
} else if (
|
||||
unsentMessages.length === 1 &&
|
||||
unsentMessages[0].error &&
|
||||
unsentMessages[0].error.data &&
|
||||
unsentMessages[0].error.data.error
|
||||
) {
|
||||
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
|
||||
} else {
|
||||
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
|
||||
}
|
||||
content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
|
||||
"You can also select individual messages to resend or cancel.",
|
||||
{ count: unsentMessages.length },
|
||||
{
|
||||
'resendText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
|
||||
'cancelText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = _t('Some of your messages have not been sent');
|
||||
}
|
||||
|
||||
return <div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ title }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{ content }
|
||||
let buttonRow = <>
|
||||
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
|
||||
{_t("Delete all")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
|
||||
{_t("Retry all")}
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
if (this.state.isResending) {
|
||||
buttonRow = <>
|
||||
<InlineSpinner w={20} h={20} />
|
||||
{/* span for css */}
|
||||
<span>{_t("Sending")}</span>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_unsentBadge">
|
||||
<NotificationBadge
|
||||
notification={StaticNotificationState.RED_EXCLAMATION}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_unsentTitle">
|
||||
{ title }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentDescription">
|
||||
{ _t("You can select all or individual messages to retry or delete") }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentButtonBar">
|
||||
{buttonRow}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
</>;
|
||||
}
|
||||
|
||||
// return suitable content for the main (text) part of the status bar.
|
||||
_getContent: function() {
|
||||
render() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ _t('Connectivity to the server has been lost.') }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{ _t('Sent messages will be stored until your connection has returned.') }
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
|
||||
height="24" title="/!\ " alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{_t('Connectivity to the server has been lost.')}
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{_t('Sent messages will be stored until your connection has returned.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.unsentMessages.length > 0) {
|
||||
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return this._getUnsentMessageContent();
|
||||
}
|
||||
|
||||
if (this.props.hasActiveCall) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_callBar">
|
||||
<b>{ _t('Active call') }</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If you're alone in the room, and have sent a message, suggest to invite someone
|
||||
if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_isAlone">
|
||||
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
|
||||
"or <nowarnText>stop warning about the empty room</nowarnText>?",
|
||||
{},
|
||||
{
|
||||
'inviteText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
|
||||
'nowarnText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
|
||||
},
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const content = this._getContent();
|
||||
const indicator = this._getIndicator();
|
||||
|
||||
return (
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div className="mx_RoomStatusBar_indicator">
|
||||
{ indicator }
|
||||
</div>
|
||||
<div role="alert">
|
||||
{ content }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,459 +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';
|
||||
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";
|
||||
|
||||
// turn this on for drop & drag console debugging galore
|
||||
const debug = false;
|
||||
|
||||
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 <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}
|
||||
/>;
|
||||
};
|
||||
|
||||
_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 = `${height}px`;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from "react";
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Key } from '../../Keyboard';
|
||||
import React, { createRef, CSSProperties, ReactNode, SyntheticEvent, KeyboardEvent } from "react";
|
||||
import Timer from '../../utils/Timer';
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
|
||||
const DEBUG_SCROLL = false;
|
||||
|
||||
// The amount of extra scroll distance to allow prior to unfilling.
|
||||
// See _getExcessHeight.
|
||||
// See getExcessHeight.
|
||||
const UNPAGINATION_PADDING = 6000;
|
||||
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
|
||||
// many scroll events causing many unfilling requests.
|
||||
@@ -43,6 +43,75 @@ if (DEBUG_SCROLL) {
|
||||
debuglog = function() {};
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
/* stickyBottom: if set to true, then once the user hits the bottom of
|
||||
* the list, any new children added to the list will cause the list to
|
||||
* scroll down to show the new element, rather than preserving the
|
||||
* existing view.
|
||||
*/
|
||||
stickyBottom?: boolean;
|
||||
|
||||
/* startAtBottom: if set to true, the view is assumed to start
|
||||
* scrolled to the bottom.
|
||||
* XXX: It's likely this is unnecessary and can be derived from
|
||||
* stickyBottom, but I'm adding an extra parameter to ensure
|
||||
* behaviour stays the same for other uses of ScrollPanel.
|
||||
* If so, let's remove this parameter down the line.
|
||||
*/
|
||||
startAtBottom?: boolean;
|
||||
|
||||
/* className: classnames to add to the top-level div
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/* style: styles to add to the top-level div
|
||||
*/
|
||||
style?: CSSProperties;
|
||||
|
||||
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
|
||||
*/
|
||||
resizeNotifier?: ResizeNotifier;
|
||||
|
||||
/* fixedChildren: allows for children to be passed which are rendered outside
|
||||
* of the wrapper
|
||||
*/
|
||||
fixedChildren?: ReactNode;
|
||||
|
||||
/* onFillRequest(backwards): a callback which is called on scroll when
|
||||
* the user nears the start (backwards = true) or end (backwards =
|
||||
* false) of the list.
|
||||
*
|
||||
* This should return a promise; no more calls will be made until the
|
||||
* promise completes.
|
||||
*
|
||||
* The promise should resolve to true if there is more data to be
|
||||
* retrieved in this direction (in which case onFillRequest may be
|
||||
* called again immediately), or false if there is no more data in this
|
||||
* directon (at this time) - which will stop the pagination cycle until
|
||||
* the user scrolls again.
|
||||
*/
|
||||
onFillRequest?(backwards: boolean): Promise<boolean>;
|
||||
|
||||
/* onUnfillRequest(backwards): a callback which is called on scroll when
|
||||
* there are children elements that are far out of view and could be removed
|
||||
* without causing pagination to occur.
|
||||
*
|
||||
* This function should accept a boolean, which is true to indicate the back/top
|
||||
* of the panel and false otherwise, and a scroll token, which refers to the
|
||||
* first element to remove if removing from the front/bottom, and last element
|
||||
* to remove if removing from the back/top.
|
||||
*/
|
||||
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
|
||||
|
||||
/* onScroll: a callback which is called whenever any scroll happens.
|
||||
*/
|
||||
onScroll?(event: Event): void;
|
||||
|
||||
/* onUserScroll: callback which is called when the user interacts with the room timeline
|
||||
*/
|
||||
onUserScroll?(event: SyntheticEvent): void;
|
||||
}
|
||||
|
||||
/* This component implements an intelligent scrolling list.
|
||||
*
|
||||
* It wraps a list of <li> children; when items are added to the start or end
|
||||
@@ -84,96 +153,61 @@ if (DEBUG_SCROLL) {
|
||||
* offset as normal.
|
||||
*/
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ScrollPanel',
|
||||
export interface IScrollState {
|
||||
stuckAtBottom: boolean;
|
||||
trackedNode?: HTMLElement;
|
||||
trackedScrollToken?: string;
|
||||
bottomOffset?: number;
|
||||
pixelOffset?: number;
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
/* stickyBottom: if set to true, then once the user hits the bottom of
|
||||
* the list, any new children added to the list will cause the list to
|
||||
* scroll down to show the new element, rather than preserving the
|
||||
* existing view.
|
||||
*/
|
||||
stickyBottom: PropTypes.bool,
|
||||
interface IPreventShrinkingState {
|
||||
offsetFromBottom: number;
|
||||
offsetNode: HTMLElement;
|
||||
}
|
||||
|
||||
/* startAtBottom: if set to true, the view is assumed to start
|
||||
* scrolled to the bottom.
|
||||
* XXX: It's likley this is unecessary and can be derived from
|
||||
* stickyBottom, but I'm adding an extra parameter to ensure
|
||||
* behaviour stays the same for other uses of ScrollPanel.
|
||||
* If so, let's remove this parameter down the line.
|
||||
*/
|
||||
startAtBottom: PropTypes.bool,
|
||||
@replaceableComponent("structures.ScrollPanel")
|
||||
export default class ScrollPanel extends React.Component<IProps> {
|
||||
static defaultProps = {
|
||||
stickyBottom: true,
|
||||
startAtBottom: true,
|
||||
onFillRequest: function(backwards: boolean) { return Promise.resolve(false); },
|
||||
onUnfillRequest: function(backwards: boolean, scrollToken: string) {},
|
||||
onScroll: function() {},
|
||||
};
|
||||
|
||||
/* onFillRequest(backwards): a callback which is called on scroll when
|
||||
* the user nears the start (backwards = true) or end (backwards =
|
||||
* false) of the list.
|
||||
*
|
||||
* This should return a promise; no more calls will be made until the
|
||||
* promise completes.
|
||||
*
|
||||
* The promise should resolve to true if there is more data to be
|
||||
* retrieved in this direction (in which case onFillRequest may be
|
||||
* called again immediately), or false if there is no more data in this
|
||||
* directon (at this time) - which will stop the pagination cycle until
|
||||
* the user scrolls again.
|
||||
*/
|
||||
onFillRequest: PropTypes.func,
|
||||
private readonly pendingFillRequests: Record<"b" | "f", boolean> = {
|
||||
b: null,
|
||||
f: null,
|
||||
};
|
||||
private readonly itemlist = createRef<HTMLOListElement>();
|
||||
private unmounted = false;
|
||||
private scrollTimeout: Timer;
|
||||
private isFilling: boolean;
|
||||
private fillRequestWhileRunning: boolean;
|
||||
private scrollState: IScrollState;
|
||||
private preventShrinkingState: IPreventShrinkingState;
|
||||
private unfillDebouncer: number;
|
||||
private bottomGrowth: number;
|
||||
private pages: number;
|
||||
private heightUpdateInProgress: boolean;
|
||||
private divScroll: HTMLDivElement;
|
||||
|
||||
/* onUnfillRequest(backwards): a callback which is called on scroll when
|
||||
* there are children elements that are far out of view and could be removed
|
||||
* without causing pagination to occur.
|
||||
*
|
||||
* This function should accept a boolean, which is true to indicate the back/top
|
||||
* of the panel and false otherwise, and a scroll token, which refers to the
|
||||
* first element to remove if removing from the front/bottom, and last element
|
||||
* to remove if removing from the back/top.
|
||||
*/
|
||||
onUnfillRequest: PropTypes.func,
|
||||
|
||||
/* onScroll: a callback which is called whenever any scroll happens.
|
||||
*/
|
||||
onScroll: PropTypes.func,
|
||||
|
||||
/* className: classnames to add to the top-level div
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
|
||||
/* style: styles to add to the top-level div
|
||||
*/
|
||||
style: PropTypes.object,
|
||||
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
|
||||
*/
|
||||
resizeNotifier: PropTypes.object,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
stickyBottom: true,
|
||||
startAtBottom: true,
|
||||
onFillRequest: function(backwards) { return Promise.resolve(false); },
|
||||
onUnfillRequest: function(backwards, scrollToken) {},
|
||||
onScroll: function() {},
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._pendingFillRequests = {b: null, f: null};
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
|
||||
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
|
||||
this.resetScrollState();
|
||||
}
|
||||
|
||||
this._itemlist = createRef();
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.checkScroll();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidUpdate: function() {
|
||||
componentDidUpdate() {
|
||||
// after adding event tiles, we may need to tweak the scroll (either to
|
||||
// keep at the bottom of the timeline, or to maintain the view after
|
||||
// adding events to the top).
|
||||
@@ -181,9 +215,9 @@ export default createReactClass({
|
||||
// This will also re-check the fill state, in case the paginate was inadequate
|
||||
this.checkScroll();
|
||||
this.updatePreventShrinking();
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
// set a boolean to say we've been unmounted, which any pending
|
||||
// promises can use to throw away their results.
|
||||
//
|
||||
@@ -191,51 +225,53 @@ export default createReactClass({
|
||||
this.unmounted = true;
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
|
||||
this.props.resizeNotifier.removeListener("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onScroll: function(ev) {
|
||||
debuglog("onScroll", this._getScrollNode().scrollTop);
|
||||
this._scrollTimeout.restart();
|
||||
this._saveScrollState();
|
||||
private onScroll = ev => {
|
||||
// skip scroll events caused by resizing
|
||||
if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
|
||||
debuglog("onScroll", this.getScrollNode().scrollTop);
|
||||
this.scrollTimeout.restart();
|
||||
this.saveScrollState();
|
||||
this.updatePreventShrinking();
|
||||
this.props.onScroll(ev);
|
||||
this.checkFillState();
|
||||
},
|
||||
};
|
||||
|
||||
onResize: function() {
|
||||
private onResize = () => {
|
||||
debuglog("onResize");
|
||||
this.checkScroll();
|
||||
// update preventShrinkingState if present
|
||||
if (this.preventShrinkingState) {
|
||||
this.preventShrinking();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
// where it ought to be, and set off pagination requests if necessary.
|
||||
checkScroll: function() {
|
||||
public checkScroll = () => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this._restoreSavedScrollState();
|
||||
this.restoreSavedScrollState();
|
||||
this.checkFillState();
|
||||
},
|
||||
};
|
||||
|
||||
// return true if the content is fully scrolled down right now; else false.
|
||||
//
|
||||
// note that this is independent of the 'stuckAtBottom' state - it is simply
|
||||
// about whether the content is scrolled down right now, irrespective of
|
||||
// whether it will stay that way when the children update.
|
||||
isAtBottom: function() {
|
||||
const sn = this._getScrollNode();
|
||||
public isAtBottom = () => {
|
||||
const sn = this.getScrollNode();
|
||||
// fractional values (both too big and too small)
|
||||
// for scrollTop happen on certain browsers/platforms
|
||||
// when scrolled all the way down. E.g. Chrome 72 on debian.
|
||||
// so check difference <= 1;
|
||||
return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1;
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
// returns the vertical height in the given direction that can be removed from
|
||||
// the content box (which has a height of scrollHeight, see checkFillState) without
|
||||
@@ -268,10 +304,10 @@ export default createReactClass({
|
||||
// |#########| - |
|
||||
// |#########| |
|
||||
// `---------' -
|
||||
_getExcessHeight: function(backwards) {
|
||||
const sn = this._getScrollNode();
|
||||
const contentHeight = this._getMessagesHeight();
|
||||
const listHeight = this._getListHeight();
|
||||
private getExcessHeight(backwards: boolean): number {
|
||||
const sn = this.getScrollNode();
|
||||
const contentHeight = this.getMessagesHeight();
|
||||
const listHeight = this.getListHeight();
|
||||
const clippedHeight = contentHeight - listHeight;
|
||||
const unclippedScrollTop = sn.scrollTop + clippedHeight;
|
||||
|
||||
@@ -280,16 +316,16 @@ export default createReactClass({
|
||||
} else {
|
||||
return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
checkFillState: async function(depth=0) {
|
||||
public checkFillState = async (depth = 0): Promise<void> => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstCall = depth === 0;
|
||||
const sn = this._getScrollNode();
|
||||
const sn = this.getScrollNode();
|
||||
|
||||
// if there is less than a screenful of messages above or below the
|
||||
// viewport, try to get some more messages.
|
||||
@@ -320,17 +356,17 @@ export default createReactClass({
|
||||
// do make a note when a new request comes in while already running one,
|
||||
// so we can trigger a new chain of calls once done.
|
||||
if (isFirstCall) {
|
||||
if (this._isFilling) {
|
||||
debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request");
|
||||
this._fillRequestWhileRunning = true;
|
||||
if (this.isFilling) {
|
||||
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
|
||||
this.fillRequestWhileRunning = true;
|
||||
return;
|
||||
}
|
||||
debuglog("_isFilling: setting");
|
||||
this._isFilling = true;
|
||||
debuglog("isFilling: setting");
|
||||
this.isFilling = true;
|
||||
}
|
||||
|
||||
const itemlist = this._itemlist.current;
|
||||
const firstTile = itemlist && itemlist.firstElementChild;
|
||||
const itemlist = this.itemlist.current;
|
||||
const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
|
||||
const contentTop = firstTile && firstTile.offsetTop;
|
||||
const fillPromises = [];
|
||||
|
||||
@@ -338,13 +374,13 @@ export default createReactClass({
|
||||
// try backward filling
|
||||
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
|
||||
// need to back-fill
|
||||
fillPromises.push(this._maybeFill(depth, true));
|
||||
fillPromises.push(this.maybeFill(depth, true));
|
||||
}
|
||||
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
|
||||
// try forward filling
|
||||
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
|
||||
// need to forward-fill
|
||||
fillPromises.push(this._maybeFill(depth, false));
|
||||
fillPromises.push(this.maybeFill(depth, false));
|
||||
}
|
||||
|
||||
if (fillPromises.length) {
|
||||
@@ -355,26 +391,26 @@ export default createReactClass({
|
||||
}
|
||||
}
|
||||
if (isFirstCall) {
|
||||
debuglog("_isFilling: clearing");
|
||||
this._isFilling = false;
|
||||
debuglog("isFilling: clearing");
|
||||
this.isFilling = false;
|
||||
}
|
||||
|
||||
if (this._fillRequestWhileRunning) {
|
||||
this._fillRequestWhileRunning = false;
|
||||
if (this.fillRequestWhileRunning) {
|
||||
this.fillRequestWhileRunning = false;
|
||||
this.checkFillState();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// check if unfilling is possible and send an unfill request if necessary
|
||||
_checkUnfillState: function(backwards) {
|
||||
let excessHeight = this._getExcessHeight(backwards);
|
||||
private checkUnfillState(backwards: boolean): void {
|
||||
let excessHeight = this.getExcessHeight(backwards);
|
||||
if (excessHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const origExcessHeight = excessHeight;
|
||||
|
||||
const tiles = this._itemlist.current.children;
|
||||
const tiles = this.itemlist.current.children;
|
||||
|
||||
// The scroll token of the first/last tile to be unpaginated
|
||||
let markerScrollToken = null;
|
||||
@@ -403,21 +439,21 @@ export default createReactClass({
|
||||
if (markerScrollToken) {
|
||||
// Use a debouncer to prevent multiple unfill calls in quick succession
|
||||
// This is to make the unfilling process less aggressive
|
||||
if (this._unfillDebouncer) {
|
||||
clearTimeout(this._unfillDebouncer);
|
||||
if (this.unfillDebouncer) {
|
||||
clearTimeout(this.unfillDebouncer);
|
||||
}
|
||||
this._unfillDebouncer = setTimeout(() => {
|
||||
this._unfillDebouncer = null;
|
||||
this.unfillDebouncer = setTimeout(() => {
|
||||
this.unfillDebouncer = null;
|
||||
debuglog("unfilling now", backwards, origExcessHeight);
|
||||
this.props.onUnfillRequest(backwards, markerScrollToken);
|
||||
}, UNFILL_REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// check if there is already a pending fill request. If not, set one off.
|
||||
_maybeFill: function(depth, backwards) {
|
||||
private maybeFill(depth: number, backwards: boolean): Promise<void> {
|
||||
const dir = backwards ? 'b' : 'f';
|
||||
if (this._pendingFillRequests[dir]) {
|
||||
if (this.pendingFillRequests[dir]) {
|
||||
debuglog("Already a "+dir+" fill in progress - not starting another");
|
||||
return;
|
||||
}
|
||||
@@ -426,7 +462,7 @@ export default createReactClass({
|
||||
|
||||
// onFillRequest can end up calling us recursively (via onScroll
|
||||
// events) so make sure we set this before firing off the call.
|
||||
this._pendingFillRequests[dir] = true;
|
||||
this.pendingFillRequests[dir] = true;
|
||||
|
||||
// wait 1ms before paginating, because otherwise
|
||||
// this will block the scroll event handler for +700ms
|
||||
@@ -435,13 +471,13 @@ export default createReactClass({
|
||||
return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
|
||||
return this.props.onFillRequest(backwards);
|
||||
}).finally(() => {
|
||||
this._pendingFillRequests[dir] = false;
|
||||
this.pendingFillRequests[dir] = false;
|
||||
}).then((hasMoreResults) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
// Unpaginate once filling is complete
|
||||
this._checkUnfillState(!backwards);
|
||||
this.checkUnfillState(!backwards);
|
||||
|
||||
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
|
||||
if (hasMoreResults) {
|
||||
@@ -451,7 +487,7 @@ export default createReactClass({
|
||||
return this.checkFillState(depth + 1);
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
/* get the current scroll state. This returns an object with the following
|
||||
* properties:
|
||||
@@ -467,9 +503,7 @@ export default createReactClass({
|
||||
* the number of pixels the bottom of the tracked child is above the
|
||||
* bottom of the scroll panel.
|
||||
*/
|
||||
getScrollState: function() {
|
||||
return this.scrollState;
|
||||
},
|
||||
public getScrollState = (): IScrollState => this.scrollState;
|
||||
|
||||
/* reset the saved scroll state.
|
||||
*
|
||||
@@ -483,80 +517,78 @@ export default createReactClass({
|
||||
* no use if no children exist yet, or if you are about to replace the
|
||||
* child list.)
|
||||
*/
|
||||
resetScrollState: function() {
|
||||
public resetScrollState = (): void => {
|
||||
this.scrollState = {
|
||||
stuckAtBottom: this.props.startAtBottom,
|
||||
};
|
||||
this._bottomGrowth = 0;
|
||||
this._pages = 0;
|
||||
this._scrollTimeout = new Timer(100);
|
||||
this._heightUpdateInProgress = false;
|
||||
},
|
||||
this.bottomGrowth = 0;
|
||||
this.pages = 0;
|
||||
this.scrollTimeout = new Timer(100);
|
||||
this.heightUpdateInProgress = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* jump to the top of the content.
|
||||
*/
|
||||
scrollToTop: function() {
|
||||
this._getScrollNode().scrollTop = 0;
|
||||
this._saveScrollState();
|
||||
},
|
||||
public scrollToTop = (): void => {
|
||||
this.getScrollNode().scrollTop = 0;
|
||||
this.saveScrollState();
|
||||
};
|
||||
|
||||
/**
|
||||
* jump to the bottom of the content.
|
||||
*/
|
||||
scrollToBottom: function() {
|
||||
public scrollToBottom = (): void => {
|
||||
// the easiest way to make sure that the scroll state is correctly
|
||||
// saved is to do the scroll, then save the updated state. (Calculating
|
||||
// it ourselves is hard, and we can't rely on an onScroll callback
|
||||
// happening, since there may be no user-visible change here).
|
||||
const sn = this._getScrollNode();
|
||||
const sn = this.getScrollNode();
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
this._saveScrollState();
|
||||
},
|
||||
this.saveScrollState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Page up/down.
|
||||
*
|
||||
* @param {number} mult: -1 to page up, +1 to page down
|
||||
*/
|
||||
scrollRelative: function(mult) {
|
||||
const scrollNode = this._getScrollNode();
|
||||
const delta = mult * scrollNode.clientHeight * 0.5;
|
||||
public scrollRelative = (mult: number): void => {
|
||||
const scrollNode = this.getScrollNode();
|
||||
const delta = mult * scrollNode.clientHeight * 0.9;
|
||||
scrollNode.scrollBy(0, delta);
|
||||
this._saveScrollState();
|
||||
},
|
||||
this.saveScrollState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll up/down in response to a scroll key
|
||||
* @param {object} ev the keyboard event
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
switch (ev.key) {
|
||||
case Key.PAGE_UP:
|
||||
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
this.scrollRelative(-1);
|
||||
}
|
||||
public handleScrollKey = (ev: KeyboardEvent) => {
|
||||
let isScrolling = false;
|
||||
const roomAction = getKeyBindingsManager().getRoomAction(ev);
|
||||
switch (roomAction) {
|
||||
case RoomAction.ScrollUp:
|
||||
this.scrollRelative(-1);
|
||||
isScrolling = true;
|
||||
break;
|
||||
|
||||
case Key.PAGE_DOWN:
|
||||
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
this.scrollRelative(1);
|
||||
}
|
||||
case RoomAction.RoomScrollDown:
|
||||
this.scrollRelative(1);
|
||||
isScrolling = true;
|
||||
break;
|
||||
|
||||
case Key.HOME:
|
||||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
this.scrollToTop();
|
||||
}
|
||||
case RoomAction.JumpToFirstMessage:
|
||||
this.scrollToTop();
|
||||
isScrolling = true;
|
||||
break;
|
||||
|
||||
case Key.END:
|
||||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
case RoomAction.JumpToLatestMessage:
|
||||
this.scrollToBottom();
|
||||
isScrolling = true;
|
||||
break;
|
||||
}
|
||||
},
|
||||
if (isScrolling && this.props.onUserScroll) {
|
||||
this.props.onUserScroll(ev);
|
||||
}
|
||||
};
|
||||
|
||||
/* Scroll the panel to bring the DOM node with the scroll token
|
||||
* `scrollToken` into view.
|
||||
@@ -569,17 +601,17 @@ export default createReactClass({
|
||||
* node (specifically, the bottom of it) will be positioned. If omitted, it
|
||||
* defaults to 0.
|
||||
*/
|
||||
scrollToToken: function(scrollToken, pixelOffset, offsetBase) {
|
||||
public scrollToToken = (scrollToken: string, pixelOffset: number, offsetBase: number): void => {
|
||||
pixelOffset = pixelOffset || 0;
|
||||
offsetBase = offsetBase || 0;
|
||||
|
||||
// set the trackedScrollToken so we can get the node through _getTrackedNode
|
||||
// set the trackedScrollToken so we can get the node through getTrackedNode
|
||||
this.scrollState = {
|
||||
stuckAtBottom: false,
|
||||
trackedScrollToken: scrollToken,
|
||||
};
|
||||
const trackedNode = this._getTrackedNode();
|
||||
const scrollNode = this._getScrollNode();
|
||||
const trackedNode = this.getTrackedNode();
|
||||
const scrollNode = this.getScrollNode();
|
||||
if (trackedNode) {
|
||||
// set the scrollTop to the position we want.
|
||||
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
|
||||
@@ -587,36 +619,36 @@ export default createReactClass({
|
||||
// This because when setting the scrollTop only 10 or so events might be loaded,
|
||||
// not giving enough content below the trackedNode to scroll downwards
|
||||
// enough so it ends up in the top of the viewport.
|
||||
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
|
||||
debuglog("scrollToken: setting scrollTop", { offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop });
|
||||
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
|
||||
this._saveScrollState();
|
||||
this.saveScrollState();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_saveScrollState: function() {
|
||||
private saveScrollState(): void {
|
||||
if (this.props.stickyBottom && this.isAtBottom()) {
|
||||
this.scrollState = { stuckAtBottom: true };
|
||||
debuglog("saved stuckAtBottom state");
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollNode = this._getScrollNode();
|
||||
const scrollNode = this.getScrollNode();
|
||||
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
|
||||
|
||||
const itemlist = this._itemlist.current;
|
||||
const itemlist = this.itemlist.current;
|
||||
const messages = itemlist.children;
|
||||
let node = null;
|
||||
|
||||
// TODO: do a binary search here, as items are sorted by offsetTop
|
||||
// loop backwards, from bottom-most message (as that is the most common case)
|
||||
for (let i = messages.length-1; i >= 0; --i) {
|
||||
if (!messages[i].dataset.scrollTokens) {
|
||||
for (let i = messages.length - 1; i >= 0; --i) {
|
||||
if (!(messages[i] as HTMLElement).dataset.scrollTokens) {
|
||||
continue;
|
||||
}
|
||||
node = messages[i];
|
||||
// break at the first message (coming from the bottom)
|
||||
// that has it's offsetTop above the bottom of the viewport.
|
||||
if (this._topFromBottom(node) > viewportBottom) {
|
||||
if (this.topFromBottom(node) > viewportBottom) {
|
||||
// Use this node as the scrollToken
|
||||
break;
|
||||
}
|
||||
@@ -628,7 +660,7 @@ export default createReactClass({
|
||||
}
|
||||
const scrollToken = node.dataset.scrollTokens.split(',')[0];
|
||||
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
|
||||
const bottomOffset = this._topFromBottom(node);
|
||||
const bottomOffset = this.topFromBottom(node);
|
||||
this.scrollState = {
|
||||
stuckAtBottom: false,
|
||||
trackedNode: node,
|
||||
@@ -636,43 +668,49 @@ export default createReactClass({
|
||||
bottomOffset: bottomOffset,
|
||||
pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
_restoreSavedScrollState: async function() {
|
||||
private async restoreSavedScrollState(): Promise<void> {
|
||||
const scrollState = this.scrollState;
|
||||
|
||||
if (scrollState.stuckAtBottom) {
|
||||
const sn = this._getScrollNode();
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
const sn = this.getScrollNode();
|
||||
if (sn.scrollTop !== sn.scrollHeight) {
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
}
|
||||
} else if (scrollState.trackedScrollToken) {
|
||||
const itemlist = this._itemlist.current;
|
||||
const trackedNode = this._getTrackedNode();
|
||||
const itemlist = this.itemlist.current;
|
||||
const trackedNode = this.getTrackedNode();
|
||||
if (trackedNode) {
|
||||
const newBottomOffset = this._topFromBottom(trackedNode);
|
||||
const newBottomOffset = this.topFromBottom(trackedNode);
|
||||
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
|
||||
this._bottomGrowth += bottomDiff;
|
||||
this.bottomGrowth += bottomDiff;
|
||||
scrollState.bottomOffset = newBottomOffset;
|
||||
itemlist.style.height = `${this._getListHeight()}px`;
|
||||
const newHeight = `${this.getListHeight()}px`;
|
||||
if (itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
debuglog("balancing height because messages below viewport grew by", bottomDiff);
|
||||
}
|
||||
}
|
||||
if (!this._heightUpdateInProgress) {
|
||||
this._heightUpdateInProgress = true;
|
||||
if (!this.heightUpdateInProgress) {
|
||||
this.heightUpdateInProgress = true;
|
||||
try {
|
||||
await this._updateHeight();
|
||||
await this.updateHeight();
|
||||
} finally {
|
||||
this._heightUpdateInProgress = false;
|
||||
this.heightUpdateInProgress = false;
|
||||
}
|
||||
} else {
|
||||
debuglog("not updating height because request already in progress");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
|
||||
async _updateHeight() {
|
||||
private async updateHeight(): Promise<void> {
|
||||
// wait until user has stopped scrolling
|
||||
if (this._scrollTimeout.isRunning()) {
|
||||
if (this.scrollTimeout.isRunning()) {
|
||||
debuglog("updateHeight waiting for scrolling to end ... ");
|
||||
await this._scrollTimeout.finished();
|
||||
await this.scrollTimeout.finished();
|
||||
} else {
|
||||
debuglog("updateHeight getting straight to business, no scrolling going on.");
|
||||
}
|
||||
@@ -682,29 +720,35 @@ export default createReactClass({
|
||||
return;
|
||||
}
|
||||
|
||||
const sn = this._getScrollNode();
|
||||
const itemlist = this._itemlist.current;
|
||||
const contentHeight = this._getMessagesHeight();
|
||||
const sn = this.getScrollNode();
|
||||
const itemlist = this.itemlist.current;
|
||||
const contentHeight = this.getMessagesHeight();
|
||||
const minHeight = sn.clientHeight;
|
||||
const height = Math.max(minHeight, contentHeight);
|
||||
this._pages = Math.ceil(height / PAGE_SIZE);
|
||||
this._bottomGrowth = 0;
|
||||
const newHeight = this._getListHeight();
|
||||
this.pages = Math.ceil(height / PAGE_SIZE);
|
||||
this.bottomGrowth = 0;
|
||||
const newHeight = `${this.getListHeight()}px`;
|
||||
|
||||
const scrollState = this.scrollState;
|
||||
if (scrollState.stuckAtBottom) {
|
||||
itemlist.style.height = `${newHeight}px`;
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
if (itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
if (sn.scrollTop !== sn.scrollHeight) {
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
}
|
||||
debuglog("updateHeight to", newHeight);
|
||||
} else if (scrollState.trackedScrollToken) {
|
||||
const trackedNode = this._getTrackedNode();
|
||||
const trackedNode = this.getTrackedNode();
|
||||
// if the timeline has been reloaded
|
||||
// this can be called before scrollToBottom or whatever has been called
|
||||
// so don't do anything if the node has disappeared from
|
||||
// the currently filled piece of the timeline
|
||||
if (trackedNode) {
|
||||
const oldTop = trackedNode.offsetTop;
|
||||
itemlist.style.height = `${newHeight}px`;
|
||||
if (itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
const newTop = trackedNode.offsetTop;
|
||||
const topDiff = newTop - oldTop;
|
||||
// important to scroll by a relative amount as
|
||||
@@ -712,22 +756,22 @@ export default createReactClass({
|
||||
// yield out of date values and cause a jump
|
||||
// when setting it
|
||||
sn.scrollBy(0, topDiff);
|
||||
debuglog("updateHeight to", {newHeight, topDiff});
|
||||
debuglog("updateHeight to", { newHeight, topDiff });
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_getTrackedNode() {
|
||||
private getTrackedNode(): HTMLElement {
|
||||
const scrollState = this.scrollState;
|
||||
const trackedNode = scrollState.trackedNode;
|
||||
|
||||
if (!trackedNode || !trackedNode.parentElement) {
|
||||
let node;
|
||||
const messages = this._itemlist.current.children;
|
||||
const messages = this.itemlist.current.children;
|
||||
const scrollToken = scrollState.trackedScrollToken;
|
||||
|
||||
for (let i = messages.length-1; i >= 0; --i) {
|
||||
const m = messages[i];
|
||||
const m = messages[i] as HTMLElement;
|
||||
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
|
||||
// There might only be one scroll token
|
||||
if (m.dataset.scrollTokens &&
|
||||
@@ -748,63 +792,63 @@ export default createReactClass({
|
||||
}
|
||||
|
||||
return scrollState.trackedNode;
|
||||
},
|
||||
}
|
||||
|
||||
_getListHeight() {
|
||||
return this._bottomGrowth + (this._pages * PAGE_SIZE);
|
||||
},
|
||||
private getListHeight(): number {
|
||||
return this.bottomGrowth + (this.pages * PAGE_SIZE);
|
||||
}
|
||||
|
||||
_getMessagesHeight() {
|
||||
const itemlist = this._itemlist.current;
|
||||
const lastNode = itemlist.lastElementChild;
|
||||
private getMessagesHeight(): number {
|
||||
const itemlist = this.itemlist.current;
|
||||
const lastNode = itemlist.lastElementChild as HTMLElement;
|
||||
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
|
||||
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
|
||||
const firstNodeTop = itemlist.firstElementChild ? (itemlist.firstElementChild as HTMLElement).offsetTop : 0;
|
||||
// 18 is itemlist padding
|
||||
return lastNodeBottom - firstNodeTop + (18 * 2);
|
||||
},
|
||||
}
|
||||
|
||||
_topFromBottom(node) {
|
||||
private topFromBottom(node: HTMLElement): number {
|
||||
// current capped height - distance from top = distance from bottom of container to top of tracked element
|
||||
return this._itemlist.current.clientHeight - node.offsetTop;
|
||||
},
|
||||
return this.itemlist.current.clientHeight - node.offsetTop;
|
||||
}
|
||||
|
||||
/* get the DOM node which has the scrollTop property we care about for our
|
||||
* message panel.
|
||||
*/
|
||||
_getScrollNode: function() {
|
||||
private getScrollNode(): HTMLDivElement {
|
||||
if (this.unmounted) {
|
||||
// this shouldn't happen, but when it does, turn the NPE into
|
||||
// something more meaningful.
|
||||
throw new Error("ScrollPanel._getScrollNode called when unmounted");
|
||||
throw new Error("ScrollPanel.getScrollNode called when unmounted");
|
||||
}
|
||||
|
||||
if (!this._divScroll) {
|
||||
if (!this.divScroll) {
|
||||
// Likewise, we should have the ref by this point, but if not
|
||||
// turn the NPE into something meaningful.
|
||||
throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected");
|
||||
throw new Error("ScrollPanel.getScrollNode called before AutoHideScrollbar ref collected");
|
||||
}
|
||||
|
||||
return this._divScroll;
|
||||
},
|
||||
return this.divScroll;
|
||||
}
|
||||
|
||||
_collectScroll: function(divScroll) {
|
||||
this._divScroll = divScroll;
|
||||
},
|
||||
private collectScroll = (divScroll: HTMLDivElement) => {
|
||||
this.divScroll = divScroll;
|
||||
};
|
||||
|
||||
/**
|
||||
Mark the bottom offset of the last tile so we can balance it out when
|
||||
anything below it changes, by calling updatePreventShrinking, to keep
|
||||
the same minimum bottom offset, effectively preventing the timeline to shrink.
|
||||
*/
|
||||
preventShrinking: function() {
|
||||
const messageList = this._itemlist.current;
|
||||
public preventShrinking = (): void => {
|
||||
const messageList = this.itemlist.current;
|
||||
const tiles = messageList && messageList.children;
|
||||
if (!messageList) {
|
||||
return;
|
||||
}
|
||||
let lastTileNode;
|
||||
for (let i = tiles.length - 1; i >= 0; i--) {
|
||||
const node = tiles[i];
|
||||
const node = tiles[i] as HTMLElement;
|
||||
if (node.dataset.scrollTokens) {
|
||||
lastTileNode = node;
|
||||
break;
|
||||
@@ -820,16 +864,16 @@ export default createReactClass({
|
||||
offsetNode: lastTileNode,
|
||||
};
|
||||
debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom");
|
||||
},
|
||||
};
|
||||
|
||||
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
|
||||
clearPreventShrinking: function() {
|
||||
const messageList = this._itemlist.current;
|
||||
public clearPreventShrinking = (): void => {
|
||||
const messageList = this.itemlist.current;
|
||||
const balanceElement = messageList && messageList.parentElement;
|
||||
if (balanceElement) balanceElement.style.paddingBottom = null;
|
||||
this.preventShrinkingState = null;
|
||||
debuglog("prevent shrinking cleared");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
update the container padding to balance
|
||||
@@ -839,12 +883,12 @@ export default createReactClass({
|
||||
from the bottom of the marked tile grows larger than
|
||||
what it was when marking.
|
||||
*/
|
||||
updatePreventShrinking: function() {
|
||||
public updatePreventShrinking = (): void => {
|
||||
if (this.preventShrinkingState) {
|
||||
const sn = this._getScrollNode();
|
||||
const sn = this.getScrollNode();
|
||||
const scrollState = this.scrollState;
|
||||
const messageList = this._itemlist.current;
|
||||
const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
|
||||
const messageList = this.itemlist.current;
|
||||
const { offsetNode, offsetFromBottom } = this.preventShrinkingState;
|
||||
// element used to set paddingBottom to balance the typing notifs disappearing
|
||||
const balanceElement = messageList.parentElement;
|
||||
// if the offsetNode got unmounted, clear
|
||||
@@ -869,24 +913,30 @@ export default createReactClass({
|
||||
this.clearPreventShrinking();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
// TODO: the classnames on the div and ol could do with being updated to
|
||||
// reflect the fact that we don't necessarily contain a list of messages.
|
||||
// it's not obvious why we have a separate div and ol anyway.
|
||||
|
||||
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
|
||||
// list-style-type: none; is no longer a list
|
||||
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
|
||||
return (
|
||||
<AutoHideScrollbar
|
||||
wrappedRef={this.collectScroll}
|
||||
onScroll={this.onScroll}
|
||||
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
|
||||
{ this.props.children }
|
||||
</ol>
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
},
|
||||
});
|
||||
onWheel={this.props.onUserScroll}
|
||||
className={`mx_ScrollPanel ${this.props.className}`}
|
||||
style={this.props.style}
|
||||
>
|
||||
{ this.props.fixedChildren }
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref={this.itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
|
||||
{ this.props.children }
|
||||
</ol>
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,58 +15,56 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Key } from '../../Keyboard';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { throttle } from 'lodash';
|
||||
import AccessibleButton from '../../components/views/elements/AccessibleButton';
|
||||
import classNames from 'classnames';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'SearchBox',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("structures.SearchBox")
|
||||
export default class SearchBox extends React.Component {
|
||||
static propTypes = {
|
||||
onSearch: PropTypes.func,
|
||||
onCleared: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
autoFocus: PropTypes.bool,
|
||||
initialValue: PropTypes.string,
|
||||
|
||||
// If true, the search box will focus and clear itself
|
||||
// on room search focus action (it would be nicer to take
|
||||
// this functionality out, but not obvious how that would work)
|
||||
enableRoomSearchFocus: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
enableRoomSearchFocus: false,
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
enableRoomSearchFocus: false,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
searchTerm: "",
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._search = createRef();
|
||||
|
||||
this.state = {
|
||||
searchTerm: this.props.initialValue || "",
|
||||
blurred: true,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._search = createRef();
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
}
|
||||
|
||||
onAction: function(payload) {
|
||||
onAction = payload => {
|
||||
if (!this.props.enableRoomSearchFocus) return;
|
||||
|
||||
switch (payload.action) {
|
||||
@@ -81,51 +79,51 @@ export default createReactClass({
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onChange: function() {
|
||||
onChange = () => {
|
||||
if (!this._search.current) return;
|
||||
this.setState({ searchTerm: this._search.current.value });
|
||||
this.onSearch();
|
||||
},
|
||||
};
|
||||
|
||||
onSearch: throttle(function() {
|
||||
onSearch = throttle(() => {
|
||||
this.props.onSearch(this._search.current.value);
|
||||
}, 200, {trailing: true, leading: true}),
|
||||
}, 200, { trailing: true, leading: true });
|
||||
|
||||
_onKeyDown: function(ev) {
|
||||
_onKeyDown = ev => {
|
||||
switch (ev.key) {
|
||||
case Key.ESCAPE:
|
||||
this._clearSearch("keyboard");
|
||||
break;
|
||||
}
|
||||
if (this.props.onKeyDown) this.props.onKeyDown(ev);
|
||||
},
|
||||
};
|
||||
|
||||
_onFocus: function(ev) {
|
||||
this.setState({blurred: false});
|
||||
_onFocus = ev => {
|
||||
this.setState({ blurred: false });
|
||||
ev.target.select();
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onBlur: function(ev) {
|
||||
this.setState({blurred: true});
|
||||
_onBlur = ev => {
|
||||
this.setState({ blurred: true });
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_clearSearch: function(source) {
|
||||
_clearSearch(source) {
|
||||
this._search.current.value = "";
|
||||
this.onChange();
|
||||
if (this.props.onCleared) {
|
||||
this.props.onCleared(source);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
// check for collapsed here and
|
||||
// not at parent so we keep
|
||||
// searchTerm in our state
|
||||
@@ -149,7 +147,7 @@ export default createReactClass({
|
||||
this.props.placeholder;
|
||||
const className = this.props.className || "";
|
||||
return (
|
||||
<div className={classNames("mx_SearchBox", "mx_textinput", {"mx_SearchBox_blurred": this.state.blurred})}>
|
||||
<div className={classNames("mx_SearchBox", "mx_textinput", { "mx_SearchBox_blurred": this.state.blurred })}>
|
||||
<input
|
||||
key="searchfield"
|
||||
type="text"
|
||||
@@ -162,9 +160,10 @@ export default createReactClass({
|
||||
onBlur={this._onBlur}
|
||||
placeholder={ placeholder }
|
||||
autoComplete="off"
|
||||
autoFocus={this.props.autoFocus}
|
||||
/>
|
||||
{ clearButton }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
642
src/components/structures/SpaceRoomDirectory.tsx
Normal file
642
src/components/structures/SpaceRoomDirectory.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||
import classNames from "classnames";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import { EnhancedMap } from "../../utils/maps";
|
||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import InfoTooltip from "../views/elements/InfoTooltip";
|
||||
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import { getChildOrder } from "../../stores/SpaceStore";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
refreshToken?: any;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
interface ITileProps {
|
||||
room: ISpaceSummaryRoom;
|
||||
suggested?: boolean;
|
||||
selected?: boolean;
|
||||
numChildRooms?: number;
|
||||
hasPermissions?: boolean;
|
||||
onViewRoomClick(autoJoin: boolean): void;
|
||||
onToggleClick?(): void;
|
||||
}
|
||||
|
||||
const Tile: React.FC<ITileProps> = ({
|
||||
room,
|
||||
suggested,
|
||||
selected,
|
||||
hasPermissions,
|
||||
onToggleClick,
|
||||
onViewRoomClick,
|
||||
numChildRooms,
|
||||
children,
|
||||
}) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
||||
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(false);
|
||||
};
|
||||
const onJoinClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(true);
|
||||
};
|
||||
|
||||
let button;
|
||||
if (joinedRoom) {
|
||||
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
|
||||
{ _t("View") }
|
||||
</AccessibleButton>;
|
||||
} else if (onJoinClick) {
|
||||
button = <AccessibleButton onClick={onJoinClick} kind="primary">
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let checkbox;
|
||||
if (onToggleClick) {
|
||||
if (hasPermissions) {
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
|
||||
} else {
|
||||
checkbox = <TextWithTooltip
|
||||
tooltip={_t("You don't have permission")}
|
||||
onClick={ev => { ev.stopPropagation(); }}
|
||||
>
|
||||
<StyledCheckbox disabled={true} />
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
}
|
||||
|
||||
let avatar;
|
||||
if (joinedRoom) {
|
||||
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
|
||||
} else {
|
||||
avatar = <BaseAvatar
|
||||
name={name}
|
||||
idName={room.room_id}
|
||||
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
|
||||
width={20}
|
||||
height={20}
|
||||
/>;
|
||||
}
|
||||
|
||||
let description = _t("%(count)s members", { count: room.num_joined_members });
|
||||
if (numChildRooms !== undefined) {
|
||||
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
||||
}
|
||||
|
||||
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
|
||||
if (topic) {
|
||||
description += " · " + topic;
|
||||
}
|
||||
|
||||
let suggestedSection;
|
||||
if (suggested) {
|
||||
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
|
||||
{ _t("Suggested") }
|
||||
</InfoTooltip>;
|
||||
}
|
||||
|
||||
const content = <React.Fragment>
|
||||
{ avatar }
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_name">
|
||||
{ name }
|
||||
{ suggestedSection }
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx_SpaceRoomDirectory_roomTile_info"
|
||||
ref={e => e && linkifyElement(e)}
|
||||
onClick={ev => {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
if ((ev.target as HTMLElement).tagName === "A") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ description }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomDirectory_actions">
|
||||
{ button }
|
||||
{ checkbox }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
let childToggle;
|
||||
let childSection;
|
||||
if (children) {
|
||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||
childToggle = <div
|
||||
className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
|
||||
mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
|
||||
})}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
toggleShowChildren();
|
||||
}}
|
||||
/>;
|
||||
if (showChildren) {
|
||||
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
|
||||
{ children }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
||||
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
||||
})}
|
||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||
>
|
||||
{ content }
|
||||
{ childToggle }
|
||||
</AccessibleButton>
|
||||
{ childSection }
|
||||
</>;
|
||||
};
|
||||
|
||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
||||
// Don't let the user view a room they won't be able to either peek or join:
|
||||
// fail earlier so they don't have to click back to the directory.
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const roomAlias = getDisplayAliasForRoom(room) || undefined;
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
auto_join: autoJoin,
|
||||
should_peek: true,
|
||||
_type: "room_directory", // instrumentation
|
||||
room_alias: roomAlias,
|
||||
room_id: room.room_id,
|
||||
via_servers: viaServers,
|
||||
oob_data: {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
||||
name: room.name || roomAlias || _t("Unnamed room"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
interface IHierarchyLevelProps {
|
||||
spaceId: string;
|
||||
rooms: Map<string, ISpaceSummaryRoom>;
|
||||
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
|
||||
parents: Set<string>;
|
||||
selectedMap?: Map<string, Set<string>>;
|
||||
onViewRoomClick(roomId: string, autoJoin: boolean): void;
|
||||
onToggleClick?(parentId: string, childId: string): void;
|
||||
}
|
||||
|
||||
export const HierarchyLevel = ({
|
||||
spaceId,
|
||||
rooms,
|
||||
relations,
|
||||
parents,
|
||||
selectedMap,
|
||||
onViewRoomClick,
|
||||
onToggleClick,
|
||||
}: IHierarchyLevelProps) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const space = cli.getRoom(spaceId);
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
const children = Array.from(relations.get(spaceId)?.values() || []);
|
||||
const sortedChildren = sortBy(children, ev => {
|
||||
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
|
||||
return getChildOrder(ev.content.order, null, ev.state_key);
|
||||
});
|
||||
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
|
||||
const roomId = ev.state_key;
|
||||
if (!rooms.has(roomId)) return result;
|
||||
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
|
||||
return result;
|
||||
}, [[], []]) || [[], []];
|
||||
|
||||
const newParents = new Set(parents).add(spaceId);
|
||||
return <React.Fragment>
|
||||
{
|
||||
childRooms.map(roomId => (
|
||||
<Tile
|
||||
key={roomId}
|
||||
room={rooms.get(roomId)}
|
||||
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
||||
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(roomId, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
|
||||
<Tile
|
||||
key={roomId}
|
||||
room={rooms.get(roomId)}
|
||||
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
|
||||
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
|
||||
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
||||
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(roomId, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
||||
>
|
||||
<HierarchyLevel
|
||||
spaceId={roomId}
|
||||
rooms={rooms}
|
||||
relations={relations}
|
||||
parents={newParents}
|
||||
selectedMap={selectedMap}
|
||||
onViewRoomClick={onViewRoomClick}
|
||||
onToggleClick={onToggleClick}
|
||||
/>
|
||||
</Tile>
|
||||
))
|
||||
}
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
// mutate argument refreshToken to force a reload
|
||||
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// TODO pagination
|
||||
return useAsyncMemo(async () => {
|
||||
try {
|
||||
const data = await cli.getSpaceSummary(space.roomId);
|
||||
|
||||
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
|
||||
const childParentRelations = new EnhancedMap<string, Set<string>>();
|
||||
const viaMap = new EnhancedMap<string, Set<string>>();
|
||||
data.events.map((ev: ISpaceSummaryEvent) => {
|
||||
if (ev.type === EventType.SpaceChild) {
|
||||
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
|
||||
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
|
||||
}
|
||||
if (Array.isArray(ev.content.via)) {
|
||||
const set = viaMap.getOrCreate(ev.state_key, new Set());
|
||||
ev.content.via.forEach(via => set.add(via));
|
||||
}
|
||||
});
|
||||
|
||||
return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
|
||||
} catch (e) {
|
||||
console.error(e); // TODO
|
||||
return [e];
|
||||
}
|
||||
}, [space, refreshToken], [undefined]);
|
||||
};
|
||||
|
||||
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
||||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
refreshToken,
|
||||
additionalButtons,
|
||||
children,
|
||||
}) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
const [query, setQuery] = useState(initialText);
|
||||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
const lcQuery = query.toLowerCase().trim();
|
||||
|
||||
const roomsMap = new Map<string, ISpaceSummaryRoom>(rooms.map(r => [r.room_id, r]));
|
||||
if (!lcQuery) return roomsMap;
|
||||
|
||||
const directMatches = rooms.filter(r => {
|
||||
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
|
||||
});
|
||||
|
||||
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
|
||||
const visited = new Set<string>();
|
||||
const queue = [...directMatches.map(r => r.room_id)];
|
||||
while (queue.length) {
|
||||
const roomId = queue.pop();
|
||||
visited.add(roomId);
|
||||
childParentMap.get(roomId)?.forEach(parentId => {
|
||||
if (!visited.has(parentId)) {
|
||||
queue.push(parentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove any mappings for rooms which were not visited in the walk
|
||||
Array.from(roomsMap.keys()).forEach(roomId => {
|
||||
if (!visited.has(roomId)) {
|
||||
roomsMap.delete(roomId);
|
||||
}
|
||||
});
|
||||
return roomsMap;
|
||||
}, [rooms, childParentMap, query]);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (summaryError) {
|
||||
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
}
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||
|
||||
let countsStr;
|
||||
if (numSpaces > 1) {
|
||||
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
|
||||
} else if (numSpaces > 0) {
|
||||
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
|
||||
} else {
|
||||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
||||
}
|
||||
|
||||
let manageButtons;
|
||||
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
manageButtons = <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).delete(childId);
|
||||
if (parentChildMap.get(parentId).size > 0) {
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
} else {
|
||||
parentChildMap.delete(parentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
let results;
|
||||
if (roomsMap.size) {
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
relations={parentChildMap}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? (parentId, childId) => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
} : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
||||
}}
|
||||
/>
|
||||
{ children && <hr /> }
|
||||
</>;
|
||||
} else {
|
||||
results = <div className="mx_SpaceRoomDirectory_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ countsStr }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
||||
{ results }
|
||||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
// TODO loading state/error state
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Search names and descriptions") }
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
/>
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }) => {
|
||||
const onCreateRoomClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={space} height={32} width={32} />
|
||||
<div>
|
||||
<h1>{ _t("Explore rooms") }</h1>
|
||||
<div><RoomName room={space} /></div>
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
|
||||
<div className="mx_Dialog_content">
|
||||
{ _t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
||||
null,
|
||||
{ a: sub => {
|
||||
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
|
||||
} },
|
||||
) }
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
||||
showRoom(room, viaServers, autoJoin);
|
||||
onFinished();
|
||||
}}
|
||||
initialText={initialText}
|
||||
>
|
||||
<AccessibleButton
|
||||
onClick={onCreateRoomClick}
|
||||
kind="primary"
|
||||
className="mx_SpaceRoomDirectory_createRoom"
|
||||
>
|
||||
{ _t("Create room") }
|
||||
</AccessibleButton>
|
||||
</SpaceHierarchy>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceRoomDirectory;
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
927
src/components/structures/SpaceRoomView.tsx
Normal file
927
src/components/structures/SpaceRoomView.tsx
Normal file
@@ -0,0 +1,927 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { RefObject, useContext, useRef, useState } from "react";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventSubscription } from "fbemitter";
|
||||
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import RoomTopic from "../views/elements/RoomTopic";
|
||||
import InlineSpinner from "../views/elements/InlineSpinner";
|
||||
import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite";
|
||||
import { useRoomMembers } from "../../hooks/useRoomMembers";
|
||||
import createRoom, { IOpts } from "../../createRoom";
|
||||
import Field from "../views/elements/Field";
|
||||
import { useEventEmitter } from "../../hooks/useEventEmitter";
|
||||
import withValidation from "../views/elements/Validation";
|
||||
import * as Email from "../../email";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import MainSplit from './MainSplit';
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import RightPanel from "./RightPanel";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
|
||||
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
|
||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { BetaPill } from "../views/beta/BetaCard";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
import Modal from "../../Modal";
|
||||
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||
import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
justCreatedOpts?: IOpts;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
onJoinButtonClicked(): void;
|
||||
onRejectButtonClicked(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
createdRooms?: boolean; // internal state for the creation wizard
|
||||
showRightPanel: boolean;
|
||||
myMembership: string;
|
||||
}
|
||||
|
||||
enum Phase {
|
||||
Landing,
|
||||
PublicCreateRooms,
|
||||
PublicShare,
|
||||
PrivateScope,
|
||||
PrivateInvite,
|
||||
PrivateCreateRooms,
|
||||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
// XXX: Temporary for the Spaces Beta only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<hr />
|
||||
<div>
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
|
||||
featureId: "feature_spaces",
|
||||
});
|
||||
}}>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
||||
if (children) return children(count);
|
||||
return count;
|
||||
};
|
||||
|
||||
const useMyRoomMembership = (room: Room) => {
|
||||
const [membership, setMembership] = useState(room.getMyMembership());
|
||||
useEventEmitter(room, "Room.myMembership", () => {
|
||||
setMembership(room.getMyMembership());
|
||||
});
|
||||
return membership;
|
||||
};
|
||||
|
||||
const SpaceInfo = ({ space }) => {
|
||||
const joinRule = space.getJoinRule();
|
||||
|
||||
let visibilitySection;
|
||||
if (joinRule === "public") {
|
||||
visibilitySection = <span className="mx_SpaceRoomView_info_public">
|
||||
{ _t("Public space") }
|
||||
</span>;
|
||||
} else {
|
||||
visibilitySection = <span className="mx_SpaceRoomView_info_private">
|
||||
{ _t("Private space") }
|
||||
</span>;
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_info">
|
||||
{ visibilitySection }
|
||||
{ joinRule === "public" && <RoomMemberCount room={space}>
|
||||
{(count) => count > 0 ? (
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.RoomMemberList,
|
||||
refireParams: { space },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("%(count)s members", { count }) }
|
||||
</AccessibleButton>
|
||||
) : null}
|
||||
</RoomMemberCount> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const onBetaClick = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
};
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const spacesEnabled = SpaceStore.spacesEnabled;
|
||||
|
||||
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
|
||||
&& space.getJoinRule() !== JoinRule.Public;
|
||||
|
||||
let inviterSection;
|
||||
let joinButtons;
|
||||
if (myMembership === "join") {
|
||||
// XXX remove this when spaces leaves Beta
|
||||
joinButtons = (
|
||||
<AccessibleButton
|
||||
kind="danger_outline"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("Leave") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (myMembership === "invite") {
|
||||
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
|
||||
const inviter = inviteSender && space.getMember(inviteSender);
|
||||
|
||||
if (inviteSender) {
|
||||
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
|
||||
<MemberAvatar member={inviter} width={32} height={32} />
|
||||
<div>
|
||||
<div className="mx_SpaceRoomView_preview_inviter_name">
|
||||
{ _t("<inviter/> invites you", {}, {
|
||||
inviter: () => <b>{ inviter.name || inviteSender }</b>,
|
||||
}) }
|
||||
</div>
|
||||
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
|
||||
{ inviteSender }
|
||||
</div> : null }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
joinButtons = <>
|
||||
<AccessibleButton
|
||||
kind="secondary"
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
onRejectButtonClicked();
|
||||
}}
|
||||
>
|
||||
{ _t("Reject") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled}
|
||||
>
|
||||
{ _t("Accept") }
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
} else {
|
||||
joinButtons = (
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled || cannotJoin}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
joinButtons = <InlineSpinner />;
|
||||
}
|
||||
|
||||
let footer;
|
||||
if (!spacesEnabled) {
|
||||
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
|
||||
{ myMembership === "join"
|
||||
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", {
|
||||
spaceName: space.name,
|
||||
}, {
|
||||
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
|
||||
})
|
||||
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", {
|
||||
spaceName: space.name,
|
||||
}, {
|
||||
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
} else if (cannotJoin) {
|
||||
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
|
||||
{ _t("To view %(spaceName)s, you need an invite", {
|
||||
spaceName: space.name,
|
||||
}) }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_preview">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ inviterSection }
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
<RoomName room={space} />
|
||||
</h1>
|
||||
<SpaceInfo space={space} />
|
||||
<RoomTopic room={space}>
|
||||
{(topic, ref) =>
|
||||
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
|
||||
{ topic }
|
||||
</div>
|
||||
}
|
||||
</RoomTopic>
|
||||
{ space.getJoinRule() === "public" && <FacePile room={space} /> }
|
||||
<div className="mx_SpaceRoomView_preview_joinButtons">
|
||||
{ joinButtons }
|
||||
</div>
|
||||
{ footer }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const rect = handle.current.getBoundingClientRect();
|
||||
contextMenu = <IconizedContextMenu
|
||||
left={rect.left + window.pageXOffset + 0}
|
||||
top={rect.bottom + window.pageYOffset + 8}
|
||||
chevronFace={ChevronFace.None}
|
||||
onFinished={closeMenu}
|
||||
className="mx_RoomTile_contextMenu"
|
||||
compact
|
||||
>
|
||||
<IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Create new room")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (await showCreateNewRoom(cli, space)) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add existing room")}
|
||||
iconClassName="mx_RoomList_iconHash"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const [added] = await showAddExistingRooms(cli, space);
|
||||
if (added) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<ContextMenuButton
|
||||
kind="primary"
|
||||
inputRef={handle}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
label={_t("Add")}
|
||||
>
|
||||
{ _t("Add") }
|
||||
</ContextMenuButton>
|
||||
{ contextMenu }
|
||||
</>;
|
||||
};
|
||||
|
||||
const SpaceLanding = ({ space }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
const userId = cli.getUserId();
|
||||
|
||||
let inviteButton;
|
||||
if (myMembership === "join" && space.canInvite(userId)) {
|
||||
inviteButton = (
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
className="mx_SpaceRoomView_landing_inviteButton"
|
||||
onClick={() => {
|
||||
showRoomInviteDialog(space.roomId);
|
||||
}}
|
||||
>
|
||||
{ _t("Invite") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
const [refreshToken, forceUpdate] = useStateToggle(false);
|
||||
|
||||
let addRoomButton;
|
||||
if (canAddRooms) {
|
||||
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
if (shouldShowSpaceSettings(cli, space)) {
|
||||
settingsButton = <AccessibleTooltipButton
|
||||
className="mx_SpaceRoomView_landing_settingsButton"
|
||||
onClick={() => {
|
||||
showSpaceSettings(cli, space);
|
||||
}}
|
||||
title={_t("Settings")}
|
||||
/>;
|
||||
}
|
||||
|
||||
const onMembersClick = () => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.RoomMemberList,
|
||||
refireParams: { space },
|
||||
});
|
||||
};
|
||||
|
||||
return <div className="mx_SpaceRoomView_landing">
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<div className="mx_SpaceRoomView_landing_name">
|
||||
<RoomName room={space}>
|
||||
{(name) => {
|
||||
const tags = { name: () => <div className="mx_SpaceRoomView_landing_nameRow">
|
||||
<h1>{ name }</h1>
|
||||
</div> };
|
||||
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
|
||||
}}
|
||||
</RoomName>
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_landing_info">
|
||||
<SpaceInfo space={space} />
|
||||
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
|
||||
{ inviteButton }
|
||||
{ settingsButton }
|
||||
</div>
|
||||
<RoomTopic room={space}>
|
||||
{(topic, ref) => (
|
||||
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
|
||||
{ topic }
|
||||
</div>
|
||||
)}
|
||||
</RoomTopic>
|
||||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={showRoom}
|
||||
refreshToken={refreshToken}
|
||||
additionalButtons={addRoomButton}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const numFields = 3;
|
||||
const placeholders = [_t("General"), _t("Random"), _t("Support")];
|
||||
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
|
||||
const fields = new Array(numFields).fill(0).map((_, i) => {
|
||||
const name = "roomName" + i;
|
||||
return <Field
|
||||
key={name}
|
||||
name={name}
|
||||
type="text"
|
||||
label={_t("Room name")}
|
||||
placeholder={placeholders[i]}
|
||||
value={roomNames[i]}
|
||||
onChange={ev => setRoomName(i, ev.target.value)}
|
||||
autoFocus={i === 2}
|
||||
disabled={busy}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
|
||||
await Promise.all(filteredRoomNames.map(name => {
|
||||
return createRoom({
|
||||
createOpts: {
|
||||
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
},
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: false,
|
||||
inlineErrors: true,
|
||||
parentSpace: space,
|
||||
});
|
||||
}));
|
||||
onFinished(filteredRoomNames.length > 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to create initial space rooms", e);
|
||||
setError(_t("Failed to create initial space rooms"));
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished(false);
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (roomNames.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue");
|
||||
}
|
||||
|
||||
return <div>
|
||||
<h1>{ title }</h1>
|
||||
<div className="mx_SpaceRoomView_description">{ description }</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
|
||||
{ fields }
|
||||
</form>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
element="input"
|
||||
type="submit"
|
||||
form="mx_SpaceSetupFirstRooms"
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
||||
return <div>
|
||||
<h1>{ _t("What do you want to organise?") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("Pick rooms or conversations to add. This is just a space for you, " +
|
||||
"no one will be informed. You can add more later.") }
|
||||
</div>
|
||||
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
emptySelectionButton={
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
onFinished={onFinished}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
|
||||
return <div className="mx_SpaceRoomView_publicShare">
|
||||
<h1>{ _t("Share %(name)s", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
}) }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("It's just you at the moment, it will be even better with others.") }
|
||||
</div>
|
||||
|
||||
<SpacePublicShare space={space} />
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
|
||||
return <div className="mx_SpaceRoomView_privateScope">
|
||||
<h1>{ _t("Who are you working with?") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("Make sure the right people have access to %(name)s", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_privateScope_justMeButton"
|
||||
onClick={() => { onFinished(false); }}
|
||||
>
|
||||
<h3>{ _t("Just me") }</h3>
|
||||
<div>{ _t("A private space to organise your rooms") }</div>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_privateScope_meAndMyTeammatesButton"
|
||||
onClick={() => { onFinished(true); }}
|
||||
>
|
||||
<h3>{ _t("Me and my teammates") }</h3>
|
||||
<div>{ _t("A private space for you and your teammates") }</div>
|
||||
</AccessibleButton>
|
||||
<div className="mx_SpaceRoomView_betaWarning">
|
||||
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
|
||||
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const validateEmailRules = withValidation({
|
||||
rules: [{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
}],
|
||||
});
|
||||
|
||||
const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const numFields = 3;
|
||||
const fieldRefs: RefObject<Field>[] = [useRef(), useRef(), useRef()];
|
||||
const [emailAddresses, setEmailAddress] = useStateArray(numFields, "");
|
||||
const fields = new Array(numFields).fill(0).map((_, i) => {
|
||||
const name = "emailAddress" + i;
|
||||
return <Field
|
||||
key={name}
|
||||
name={name}
|
||||
type="text"
|
||||
label={_t("Email address")}
|
||||
placeholder={_t("Email")}
|
||||
value={emailAddresses[i]}
|
||||
onChange={ev => setEmailAddress(i, ev.target.value)}
|
||||
ref={fieldRefs[i]}
|
||||
onValidate={validateEmailRules}
|
||||
autoFocus={i === 0}
|
||||
disabled={busy}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
for (let i = 0; i < fieldRefs.length; i++) {
|
||||
const fieldRef = fieldRefs[i];
|
||||
const valid = await fieldRef.current.validate({ allowEmpty: true });
|
||||
|
||||
if (valid === false) { // true/null are allowed
|
||||
fieldRef.current.focus();
|
||||
fieldRef.current.validate({ allowEmpty: true, focused: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
const targetIds = emailAddresses.map(name => name.trim()).filter(Boolean);
|
||||
try {
|
||||
const result = await inviteMultipleToRoom(space.roomId, targetIds);
|
||||
|
||||
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error");
|
||||
if (failedUsers.length > 0) {
|
||||
console.log("Failed to invite users to space: ", result);
|
||||
setError(_t("Failed to invite the following users to your space: %(csvUsers)s", {
|
||||
csvUsers: failedUsers.join(", "),
|
||||
}));
|
||||
} else {
|
||||
onFinished();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to invite users to space: ", err);
|
||||
setError(_t("We couldn't invite those users. Please check the users you want to invite and try again."));
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (emailAddresses.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
buttonLabel = busy ? _t("Inviting...") : _t("Continue");
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_inviteTeammates">
|
||||
<h1>{ _t("Invite your teammates") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("Make sure the right people have access. You can invite more later.") }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ _t("<b>This is an experimental feature.</b> For now, " +
|
||||
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
|
||||
b: sub => <b>{ sub }</b>,
|
||||
link: () => <a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
|
||||
app.element.io
|
||||
</a>,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
|
||||
{ fields }
|
||||
</form>
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_inviteTeammates_inviteDialogButton"
|
||||
onClick={() => showRoomInviteDialog(space.roomId)}
|
||||
>
|
||||
{ _t("Invite by username") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
element="input"
|
||||
type="submit"
|
||||
form="mx_SpaceSetupPrivateInvite"
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
private readonly creator: string;
|
||||
private readonly dispatcherRef: string;
|
||||
private readonly rightPanelStoreToken: EventSubscription;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
let phase = Phase.Landing;
|
||||
|
||||
this.creator = this.props.space.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
|
||||
const showSetup = this.props.justCreatedOpts && this.context.getUserId() === this.creator;
|
||||
|
||||
if (showSetup) {
|
||||
phase = this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat
|
||||
? Phase.PublicCreateRooms : Phase.PrivateScope;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
phase,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
|
||||
myMembership: this.props.space.getMyMembership(),
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
|
||||
this.context.on("Room.myMembership", this.onMyMembership);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
this.rightPanelStoreToken.remove();
|
||||
this.context.off("Room.myMembership", this.onMyMembership);
|
||||
}
|
||||
|
||||
private onMyMembership = (room: Room, myMembership: string) => {
|
||||
if (room.roomId === this.props.space.roomId) {
|
||||
this.setState({ myMembership });
|
||||
}
|
||||
};
|
||||
|
||||
private onRightPanelStoreUpdate = () => {
|
||||
this.setState({
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
|
||||
});
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return;
|
||||
|
||||
if (payload.action === Action.ViewUser && payload.member) {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.SpaceMemberInfo,
|
||||
refireParams: {
|
||||
space: this.props.space,
|
||||
member: payload.member,
|
||||
},
|
||||
});
|
||||
} else if (payload.action === "view_3pid_invite" && payload.event) {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.Space3pidMemberInfo,
|
||||
refireParams: {
|
||||
space: this.props.space,
|
||||
event: payload.event,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.SpaceMemberList,
|
||||
refireParams: { space: this.props.space },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private goToFirstRoom = async () => {
|
||||
// TODO actually go to the first room
|
||||
|
||||
const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId);
|
||||
if (childRooms.length) {
|
||||
const room = childRooms[0];
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let suggestedRooms = SpaceStore.instance.suggestedRooms;
|
||||
if (SpaceStore.instance.activeSpace !== this.props.space) {
|
||||
// the space store has the suggested rooms loaded for a different space, fetch the right ones
|
||||
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
|
||||
}
|
||||
|
||||
if (suggestedRooms.length) {
|
||||
const room = suggestedRooms[0];
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: room.room_id,
|
||||
room_alias: room.canonical_alias || room.aliases?.[0],
|
||||
via_servers: room.viaServers,
|
||||
oobData: {
|
||||
avatarUrl: room.avatar_url,
|
||||
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ phase: Phase.Landing });
|
||||
};
|
||||
|
||||
private renderBody() {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) {
|
||||
return <SpaceLanding space={this.props.space} />;
|
||||
} else {
|
||||
return <SpacePreview
|
||||
space={this.props.space}
|
||||
onJoinButtonClicked={this.props.onJoinButtonClicked}
|
||||
onRejectButtonClicked={this.props.onRejectButtonClicked}
|
||||
/>;
|
||||
}
|
||||
case Phase.PublicCreateRooms:
|
||||
return <SpaceSetupFirstRooms
|
||||
space={this.props.space}
|
||||
title={_t("What are some things you want to discuss in %(spaceName)s?", {
|
||||
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
|
||||
})}
|
||||
description={
|
||||
_t("Let's create a room for each of them.") + "\n" +
|
||||
_t("You can add more later too, including already existing ones.")
|
||||
}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
|
||||
/>;
|
||||
case Phase.PublicShare:
|
||||
return <SpaceSetupPublicShare
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
space={this.props.space}
|
||||
onFinished={this.goToFirstRoom}
|
||||
createdRooms={this.state.createdRooms}
|
||||
/>;
|
||||
|
||||
case Phase.PrivateScope:
|
||||
return <SpaceSetupPrivateScope
|
||||
space={this.props.space}
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
onFinished={(invite: boolean) => {
|
||||
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
|
||||
}}
|
||||
/>;
|
||||
case Phase.PrivateInvite:
|
||||
return <SpaceSetupPrivateInvite
|
||||
space={this.props.space}
|
||||
onFinished={() => this.setState({ phase: Phase.PrivateCreateRooms })}
|
||||
/>;
|
||||
case Phase.PrivateCreateRooms:
|
||||
return <SpaceSetupFirstRooms
|
||||
space={this.props.space}
|
||||
title={_t("What projects are you working on?")}
|
||||
description={_t("We'll create rooms for each of them. " +
|
||||
"You can add more later too, including already existing ones.")}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
|
||||
/>;
|
||||
case Phase.PrivateExistingRooms:
|
||||
return <SpaceAddExistingRooms
|
||||
space={this.props.space}
|
||||
onFinished={() => this.setState({ phase: Phase.Landing })}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const rightPanel = this.state.showRightPanel && this.state.phase === Phase.Landing
|
||||
? <RightPanel room={this.props.space} resizeNotifier={this.props.resizeNotifier} />
|
||||
: null;
|
||||
|
||||
return <main className="mx_SpaceRoomView">
|
||||
<ErrorBoundary>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
{ this.renderBody() }
|
||||
</MainSplit>
|
||||
</ErrorBoundary>
|
||||
</main>;
|
||||
}
|
||||
}
|
||||
@@ -17,55 +17,63 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {_t} from '../../languageHandler';
|
||||
import * as PropTypes from "prop-types";
|
||||
import * as sdk from "../../index";
|
||||
import { _t } from '../../languageHandler';
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { ReactNode } from "react";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import classNames from "classnames";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
|
||||
/**
|
||||
* Represents a tab for the TabbedView.
|
||||
*/
|
||||
export class Tab {
|
||||
public label: string;
|
||||
public icon: string;
|
||||
public body: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Creates a new tab.
|
||||
* @param {string} tabLabel The untranslated tab label.
|
||||
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} tabJsx The JSX for the tab container.
|
||||
* @param {string} id The tab's ID.
|
||||
* @param {string} label The untranslated tab label.
|
||||
* @param {string} icon The class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} body The JSX for the tab container.
|
||||
*/
|
||||
constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) {
|
||||
this.label = tabLabel;
|
||||
this.icon = tabIconClass;
|
||||
this.body = tabJsx;
|
||||
constructor(public id: string, public label: string, public icon: string, public body: React.ReactNode) {
|
||||
}
|
||||
}
|
||||
|
||||
export enum TabLocation {
|
||||
LEFT = 'left',
|
||||
TOP = 'top',
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
tabs: Tab[];
|
||||
initialTabId?: string;
|
||||
tabLocation: TabLocation;
|
||||
onChange?: (tabId: string) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
activeTabIndex: number;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.TabbedView")
|
||||
export default class TabbedView extends React.Component<IProps, IState> {
|
||||
static propTypes = {
|
||||
// The tabs to show
|
||||
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
let activeTabIndex = 0;
|
||||
if (props.initialTabId) {
|
||||
const tabIndex = props.tabs.findIndex(t => t.id === props.initialTabId);
|
||||
if (tabIndex >= 0) activeTabIndex = tabIndex;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
activeTabIndex: 0,
|
||||
activeTabIndex,
|
||||
};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
tabLocation: TabLocation.LEFT,
|
||||
};
|
||||
|
||||
private _getActiveTabIndex() {
|
||||
if (!this.state || !this.state.activeTabIndex) return 0;
|
||||
return this.state.activeTabIndex;
|
||||
@@ -79,15 +87,14 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
||||
private _setActiveTab(tab: Tab) {
|
||||
const idx = this.props.tabs.indexOf(tab);
|
||||
if (idx !== -1) {
|
||||
this.setState({activeTabIndex: idx});
|
||||
if (this.props.onChange) this.props.onChange(tab.id);
|
||||
this.setState({ activeTabIndex: idx });
|
||||
} else {
|
||||
console.error("Could not find tab " + tab.label + " in tabs");
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTabLabel(tab: Tab) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let classes = "mx_TabbedView_tabLabel ";
|
||||
|
||||
const idx = this.props.tabs.indexOf(tab);
|
||||
@@ -125,8 +132,14 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
||||
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
|
||||
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);
|
||||
|
||||
const tabbedViewClasses = classNames({
|
||||
'mx_TabbedView': true,
|
||||
'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT,
|
||||
'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_TabbedView">
|
||||
<div className={tabbedViewClasses}>
|
||||
<div className="mx_TabbedView_tabLabels">
|
||||
{labels}
|
||||
</div>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/*
|
||||
Copyright 2017, 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.
|
||||
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 TagOrderStore from '../../stores/TagOrderStore';
|
||||
|
||||
import GroupActions from '../../actions/GroupActions';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
import { Droppable } from 'react-beautiful-dnd';
|
||||
import classNames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
const TagPanel = createReactClass({
|
||||
displayName: 'TagPanel',
|
||||
|
||||
statics: {
|
||||
contextType: MatrixClientContext,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
orderedTags: [],
|
||||
selectedTags: [],
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.unmounted = false;
|
||||
this.context.on("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.on("sync", this._onClientSync);
|
||||
|
||||
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
orderedTags: TagOrderStore.getOrderedTags() || [],
|
||||
selectedTags: TagOrderStore.getSelectedTags(),
|
||||
});
|
||||
});
|
||||
// This could be done by anything with a matrix client
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.removeListener("sync", this._onClientSync);
|
||||
if (this._tagOrderStoreToken) {
|
||||
this._tagOrderStoreToken.remove();
|
||||
}
|
||||
},
|
||||
|
||||
_onGroupMyMembership() {
|
||||
if (this.unmounted) return;
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
},
|
||||
|
||||
_onClientSync(syncState, prevState) {
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
if (reconnected) {
|
||||
// Load joined groups
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
}
|
||||
},
|
||||
|
||||
onMouseDown(e) {
|
||||
// only dispatch if its not a no-op
|
||||
if (this.state.selectedTags.length > 0) {
|
||||
dis.dispatch({action: 'deselect_tags'});
|
||||
}
|
||||
},
|
||||
|
||||
onCreateGroupClick(ev) {
|
||||
ev.stopPropagation();
|
||||
dis.dispatch({action: 'view_create_group'});
|
||||
},
|
||||
|
||||
onClearFilterClick(ev) {
|
||||
dis.dispatch({action: 'deselect_tags'});
|
||||
},
|
||||
|
||||
render() {
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
const TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
|
||||
const tags = this.state.orderedTags.map((tag, index) => {
|
||||
return <DNDTagTile
|
||||
key={tag}
|
||||
tag={tag}
|
||||
index={index}
|
||||
selected={this.state.selectedTags.includes(tag)}
|
||||
/>;
|
||||
});
|
||||
|
||||
const itemsSelected = this.state.selectedTags.length > 0;
|
||||
|
||||
let clearButton;
|
||||
if (itemsSelected) {
|
||||
clearButton = <AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
|
||||
<TintableSvg src={require("../../../res/img/icons-close.svg")} width="24" height="24"
|
||||
alt={_t("Clear filter")}
|
||||
title={_t("Clear filter")}
|
||||
/>
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const classes = classNames('mx_TagPanel', {
|
||||
mx_TagPanel_items_selected: itemsSelected,
|
||||
});
|
||||
|
||||
return <div className={classes}>
|
||||
<div className="mx_TagPanel_clearButton_container">
|
||||
{ clearButton }
|
||||
</div>
|
||||
<div className="mx_TagPanel_divider" />
|
||||
<AutoHideScrollbar
|
||||
className="mx_TagPanel_scroller"
|
||||
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
|
||||
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6253
|
||||
onMouseDown={this.onMouseDown}
|
||||
>
|
||||
<Droppable
|
||||
droppableId="tag-panel-droppable"
|
||||
type="draggable-TagTile"
|
||||
>
|
||||
{ (provided, snapshot) => (
|
||||
<div
|
||||
className="mx_TagPanel_tagTileContainer"
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{ tags }
|
||||
<div>
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Communities")}
|
||||
action="toggle_my_groups"
|
||||
className="mx_TagTile mx_TagTile_plus" />
|
||||
</div>
|
||||
{ provided.placeholder }
|
||||
</div>
|
||||
) }
|
||||
</Droppable>
|
||||
</AutoHideScrollbar>
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
export default TagPanel;
|
||||
@@ -1,59 +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 from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import Modal from '../../Modal';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
const TagPanelButtons = createReactClass({
|
||||
displayName: 'TagPanelButtons',
|
||||
|
||||
|
||||
componentDidMount: function() {
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._dispatcherRef) {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
this._dispatcherRef = null;
|
||||
}
|
||||
},
|
||||
|
||||
_onAction(payload) {
|
||||
if (payload.action === "show_redesign_feedback_dialog") {
|
||||
const RedesignFeedbackDialog =
|
||||
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
const GroupsButton = sdk.getComponent('elements.GroupsButton');
|
||||
const ActionButton = sdk.getComponent("elements.ActionButton");
|
||||
|
||||
return (<div className="mx_TagPanelButtons">
|
||||
<GroupsButton />
|
||||
<ActionButton
|
||||
className="mx_TagPanelButtons_report" action="show_redesign_feedback_dialog"
|
||||
label={_t("Report bugs & give feedback")} tooltip={true} />
|
||||
</div>);
|
||||
},
|
||||
});
|
||||
export default TagPanelButtons;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,23 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { _t } from '../../languageHandler';
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
import ToastStore, { IToast } from "../../stores/ToastStore";
|
||||
import classNames from "classnames";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export default class ToastContainer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {toasts: ToastStore.sharedInstance().getToasts()};
|
||||
interface IState {
|
||||
toasts: IToast<any>[];
|
||||
countSeen: number;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.ToastContainer")
|
||||
export default class ToastContainer extends React.Component<{}, IState> {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
countSeen: ToastStore.sharedInstance().getCountSeen(),
|
||||
};
|
||||
|
||||
// Start listening here rather than in componentDidMount because
|
||||
// toasts may dismiss themselves in their didMount if they find
|
||||
@@ -36,40 +45,52 @@ export default class ToastContainer extends React.Component {
|
||||
}
|
||||
|
||||
_onToastStoreUpdate = () => {
|
||||
this.setState({toasts: ToastStore.sharedInstance().getToasts()});
|
||||
this.setState({
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
countSeen: ToastStore.sharedInstance().getCountSeen(),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const totalCount = this.state.toasts.length;
|
||||
const isStacked = totalCount > 1;
|
||||
let toast;
|
||||
let containerClasses;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
const {title, icon, key, component, props} = topToast;
|
||||
const { title, icon, key, component, className, props } = topToast;
|
||||
const toastClasses = classNames("mx_Toast_toast", {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
});
|
||||
const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null;
|
||||
}, className);
|
||||
|
||||
let countIndicator;
|
||||
if (isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
const toastProps = Object.assign({}, props, {
|
||||
key,
|
||||
toastKey: key,
|
||||
});
|
||||
toast = (<div className={toastClasses}>
|
||||
<h2>{title}{countIndicator}</h2>
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{title}</h2>
|
||||
<span>{countIndicator}</span>
|
||||
</div>
|
||||
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
|
||||
</div>);
|
||||
|
||||
containerClasses = classNames("mx_ToastContainer", {
|
||||
"mx_ToastContainer_stacked": isStacked,
|
||||
});
|
||||
}
|
||||
|
||||
const containerClasses = classNames("mx_ToastContainer", {
|
||||
"mx_ToastContainer_stacked": isStacked,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClasses} role="alert">
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
return toast
|
||||
? (
|
||||
<div className={containerClasses} role="alert">
|
||||
{toast}
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -1,157 +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";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
|
||||
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 === "toggle_top_left_menu") {
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +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 ContentMessages from '../../ContentMessages';
|
||||
import dis from "../../dispatcher";
|
||||
import filesize from "filesize";
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'UploadBar',
|
||||
propTypes: {
|
||||
room: PropTypes.object,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.mounted = true;
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.mounted = false;
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
switch (payload.action) {
|
||||
case 'upload_progress':
|
||||
case 'upload_finished':
|
||||
case 'upload_canceled':
|
||||
case 'upload_failed':
|
||||
if (this.mounted) this.forceUpdate();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
|
||||
|
||||
// for testing UI... - also fix up the ContentMessages.getCurrentUploads().length
|
||||
// check in RoomView
|
||||
//
|
||||
// uploads = [{
|
||||
// roomId: this.props.room.roomId,
|
||||
// loaded: 123493,
|
||||
// total: 347534,
|
||||
// fileName: "testing_fooble.jpg",
|
||||
// }];
|
||||
|
||||
if (uploads.length == 0) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
let upload;
|
||||
for (let i = 0; i < uploads.length; ++i) {
|
||||
if (uploads[i].roomId == this.props.room.roomId) {
|
||||
upload = uploads[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!upload) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const innerProgressStyle = {
|
||||
width: ((upload.loaded / (upload.total || 1)) * 100) + '%',
|
||||
};
|
||||
let uploadedSize = filesize(upload.loaded);
|
||||
const totalSize = filesize(upload.total);
|
||||
if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) {
|
||||
uploadedSize = uploadedSize.replace(/ .*/, '');
|
||||
}
|
||||
|
||||
// MUST use var name 'count' for pluralization to kick in
|
||||
const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)});
|
||||
|
||||
return (
|
||||
<div className="mx_UploadBar">
|
||||
<div className="mx_UploadBar_uploadProgressOuter">
|
||||
<div className="mx_UploadBar_uploadProgressInner" style={innerProgressStyle}></div>
|
||||
</div>
|
||||
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src={require("../../../res/img/fileicon.png")} width="17" height="22" />
|
||||
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src={require("../../../res/img/cancel.svg")} width="18" height="18"
|
||||
onClick={function() { ContentMessages.sharedInstance().cancelUpload(upload.promise); }}
|
||||
/>
|
||||
<div className="mx_UploadBar_uploadBytes">
|
||||
{ uploadedSize } / { totalSize }
|
||||
</div>
|
||||
<div className="mx_UploadBar_uploadFilename">{ uploadText }</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
113
src/components/structures/UploadBar.tsx
Normal file
113
src/components/structures/UploadBar.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright 2015, 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import filesize from "filesize";
|
||||
import { _t } from '../../languageHandler';
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import ProgressBar from "../views/elements/ProgressBar";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { IUpload } from "../../models/IUpload";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
currentUpload?: IUpload;
|
||||
uploadsHere: IUpload[];
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.UploadBar")
|
||||
export default class UploadBar extends React.Component<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
private dispatcherRef: string;
|
||||
private mounted: boolean;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Set initial state to any available upload in this room - we might be mounting
|
||||
// earlier than the first progress event, so should show something relevant.
|
||||
const uploadsHere = this.getUploadsInRoom();
|
||||
this.state = { currentUpload: uploadsHere[0], uploadsHere };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private getUploadsInRoom(): IUpload[] {
|
||||
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
|
||||
return uploads.filter(u => u.roomId === this.props.room.roomId);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
switch (payload.action) {
|
||||
case Action.UploadStarted:
|
||||
case Action.UploadProgress:
|
||||
case Action.UploadFinished:
|
||||
case Action.UploadCanceled:
|
||||
case Action.UploadFailed: {
|
||||
if (!this.mounted) return;
|
||||
const uploadsHere = this.getUploadsInRoom();
|
||||
this.setState({ currentUpload: uploadsHere[0], uploadsHere });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onCancelClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise, this.context);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.currentUpload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MUST use var name 'count' for pluralization to kick in
|
||||
const uploadText = _t(
|
||||
"Uploading %(filename)s and %(count)s others", {
|
||||
filename: this.state.currentUpload.fileName,
|
||||
count: this.state.uploadsHere.length - 1,
|
||||
},
|
||||
);
|
||||
|
||||
const uploadSize = filesize(this.state.currentUpload.total);
|
||||
return (
|
||||
<div className="mx_UploadBar">
|
||||
<div className="mx_UploadBar_filename">{uploadText} ({uploadSize})</div>
|
||||
<AccessibleButton onClick={this.onCancelClick} className='mx_UploadBar_cancel' />
|
||||
<ProgressBar value={this.state.currentUpload.loaded} max={this.state.currentUpload.total} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
667
src/components/structures/UserMenu.tsx
Normal file
667
src/components/structures/UserMenu.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
/*
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
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 * as fbEmitter from "fbemitter";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ContextMenuButton } from "./ContextMenu";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
|
||||
import Modal from "../../Modal";
|
||||
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { getCustomTheme } from "../../theme";
|
||||
import AccessibleButton, { 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 AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import { showCommunityInviteDialog } from "../../RoomInvite";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog";
|
||||
import { UIFeature } from "../../settings/UIFeature";
|
||||
import HostSignupAction from "./HostSignupAction";
|
||||
import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import InlineSpinner from "../views/elements/InlineSpinner";
|
||||
import TooltipButton from "../views/elements/TooltipButton";
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
contextMenuPosition: PartialDOMRect;
|
||||
isDarkTheme: boolean;
|
||||
selectedSpace?: Room;
|
||||
pendingRoomJoin: Set<string>;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.UserMenu")
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
private dndWatcherRef: string;
|
||||
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
private tagStoreRef: fbEmitter.EventSubscription;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
contextMenuPosition: null,
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
pendingRoomJoin: new Set<string>(),
|
||||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
|
||||
// Force update is the easiest way to trigger the UI update (we don't store state for this)
|
||||
this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate());
|
||||
}
|
||||
|
||||
private get hasHomePage(): boolean {
|
||||
return !!getHomePageUrl(SdkConfig.get());
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
|
||||
MatrixClientPeg.get().on("Room", this.onRoom);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
|
||||
if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef);
|
||||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
this.tagStoreRef.remove();
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
MatrixClientPeg.get().removeListener("Room", this.onRoom);
|
||||
}
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
this.removePendingJoinRoom(room.roomId);
|
||||
};
|
||||
|
||||
private onTagStoreUpdate = () => {
|
||||
this.forceUpdate(); // we don't have anything useful in state to update
|
||||
};
|
||||
|
||||
private isUserOnDarkTheme(): boolean {
|
||||
if (SettingsStore.getValue("use_system_theme")) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
} else {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
}
|
||||
return theme === "dark";
|
||||
}
|
||||
}
|
||||
|
||||
private onProfileUpdate = async () => {
|
||||
// the store triggered an update, so force a layout update. We don't
|
||||
// have any state to store here for that to magically happen.
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onSelectedSpaceUpdate = async (selectedSpace?: Room) => {
|
||||
this.setState({ selectedSpace });
|
||||
};
|
||||
|
||||
private onThemeChanged = () => {
|
||||
this.setState({ isDarkTheme: this.isUserOnDarkTheme() });
|
||||
};
|
||||
|
||||
private onAction = (ev: ActionPayload) => {
|
||||
switch (ev.action) {
|
||||
case Action.ToggleUserMenu:
|
||||
if (this.state.contextMenuPosition) {
|
||||
this.setState({ contextMenuPosition: null });
|
||||
} else {
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
}
|
||||
break;
|
||||
case Action.JoinRoom:
|
||||
this.addPendingJoinRoom(ev.roomId);
|
||||
break;
|
||||
case Action.JoinRoomReady:
|
||||
case Action.JoinRoomError:
|
||||
this.removePendingJoinRoom(ev.roomId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private addPendingJoinRoom(roomId: string): void {
|
||||
this.setState({
|
||||
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin)
|
||||
.add(roomId),
|
||||
});
|
||||
}
|
||||
|
||||
private removePendingJoinRoom(roomId: string): void {
|
||||
if (this.state.pendingRoomJoin.delete(roomId)) {
|
||||
this.setState({
|
||||
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
width: 20,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const newTheme = this.state.isDarkTheme ? "light" : "dark";
|
||||
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
|
||||
};
|
||||
|
||||
private onSettingsOpen = (ev: ButtonEvent, tabId: string) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId };
|
||||
defaultDispatcher.dispatch(payload);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onShowArchived = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// TODO: Archived room view: https://github.com/vector-im/element-web/issues/14038
|
||||
// Note: You'll need to uncomment the button too.
|
||||
console.log("TODO: Show archived rooms");
|
||||
};
|
||||
|
||||
private onProvideFeedback = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = async (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
|
||||
// log out without user prompt if they have no local megolm sessions
|
||||
dis.dispatch({ action: 'logout' });
|
||||
} else {
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
}
|
||||
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onSignInClick = () => {
|
||||
dis.dispatch({ action: 'start_login' });
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onRegisterClick = () => {
|
||||
dis.dispatch({ action: 'start_registration' });
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onHomeClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({ action: 'view_home_page' });
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onCommunitySettingsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, {
|
||||
communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(),
|
||||
});
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onCommunityMembersClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// We'd ideally just pop open a right panel with the member list, but the current
|
||||
// way the right panel is structured makes this exceedingly difficult. Instead, we'll
|
||||
// switch to the general room and open the member list there as it should be in sync
|
||||
// anyways.
|
||||
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
|
||||
if (chat) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: chat.roomId,
|
||||
}, true);
|
||||
dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList });
|
||||
} else {
|
||||
// "This should never happen" clauses go here for the prototype.
|
||||
Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, {
|
||||
title: _t('Failed to find the general chat for this community'),
|
||||
description: _t("Failed to find the general chat for this community"),
|
||||
});
|
||||
}
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onCommunityInviteClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onDndToggle = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const current = SettingsStore.getValue("doNotDisturb");
|
||||
SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current);
|
||||
};
|
||||
|
||||
private renderContextMenu = (): React.ReactNode => {
|
||||
if (!this.state.contextMenuPosition) return null;
|
||||
|
||||
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
|
||||
let topSection;
|
||||
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup;
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
topSection = (
|
||||
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
|
||||
{_t("Got an account? <a>Sign in</a>", {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton kind="link" onClick={this.onSignInClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
{_t("New here? <a>Create an account</a>", {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton kind="link" onClick={this.onRegisterClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
} else if (hostSignupConfig) {
|
||||
if (hostSignupConfig && hostSignupConfig.url) {
|
||||
// If hostSignup.domains is set to a non-empty array, only show
|
||||
// dialog if the user is on the domain or a subdomain.
|
||||
const hostSignupDomains = hostSignupConfig.domains || [];
|
||||
const mxDomain = MatrixClientPeg.get().getDomain();
|
||||
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
|
||||
if (!hostSignupConfig.domains || validDomains.length > 0) {
|
||||
topSection = <HostSignupAction onClick={this.onCloseMenu} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let homeButton = null;
|
||||
if (this.hasHomePage) {
|
||||
homeButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconHome"
|
||||
label={_t("Home")}
|
||||
onClick={this.onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let feedbackButton;
|
||||
if (SettingsStore.getValue(UIFeature.Feedback)) {
|
||||
feedbackButton = <IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("Feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>;
|
||||
}
|
||||
|
||||
let primaryHeader = (
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{OwnProfileStore.instance.displayName}
|
||||
</span>
|
||||
<span className="mx_UserMenu_contextMenu_userId">
|
||||
{MatrixClientPeg.get().getUserId()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
let primaryOptionList = (
|
||||
<React.Fragment>
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("Security & privacy")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{/* <IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/> */}
|
||||
{ feedbackButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOptionList red>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</React.Fragment>
|
||||
);
|
||||
let secondarySection = null;
|
||||
|
||||
if (prototypeCommunityName) {
|
||||
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||
primaryHeader = (
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{prototypeCommunityName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
let settingsOption;
|
||||
let inviteOption;
|
||||
if (CommunityPrototypeStore.instance.canInviteTo(communityId)) {
|
||||
inviteOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconInvite"
|
||||
label={_t("Invite")}
|
||||
onClick={this.onCommunityInviteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
|
||||
settingsOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("Settings")}
|
||||
aria-label={_t("Community settings")}
|
||||
onClick={this.onCommunitySettingsClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
primaryOptionList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{settingsOption}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconMembers"
|
||||
label={_t("Members")}
|
||||
onClick={this.onCommunityMembersClick}
|
||||
/>
|
||||
{inviteOption}
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
secondarySection = (
|
||||
<React.Fragment>
|
||||
<hr />
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{OwnProfileStore.instance.displayName}
|
||||
</span>
|
||||
<span className="mx_UserMenu_contextMenu_userId">
|
||||
{MatrixClientPeg.get().getUserId()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("Settings")}
|
||||
aria-label={_t("User settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{ feedbackButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOptionList red>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else if (MatrixClientPeg.get().isGuest()) {
|
||||
primaryOptionList = (
|
||||
<React.Fragment>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ homeButton }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("Settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{ feedbackButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
"mx_UserMenu_contextMenu": true,
|
||||
"mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName,
|
||||
});
|
||||
|
||||
return <IconizedContextMenu
|
||||
// numerical adjustments to overlap the context menu by just over the width of the
|
||||
// menu icon and make it look connected
|
||||
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 10}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height + 8}
|
||||
onFinished={this.onCloseMenu}
|
||||
className={classes}
|
||||
>
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
{primaryHeader}
|
||||
<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/element-icons/roomlist/dark-light-mode.svg")}
|
||||
alt={_t("Switch theme")}
|
||||
width={16}
|
||||
/>
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{topSection}
|
||||
{primaryOptionList}
|
||||
{secondarySection}
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
public render() {
|
||||
const avatarSize = 32; // should match border-radius of the avatar
|
||||
|
||||
const userId = MatrixClientPeg.get().getUserId();
|
||||
const displayName = OwnProfileStore.instance.displayName || userId;
|
||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
|
||||
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
|
||||
let isPrototype = false;
|
||||
let menuName = _t("User menu");
|
||||
let name = <span className="mx_UserMenu_userName">{displayName}</span>;
|
||||
let buttons = (
|
||||
<span className="mx_UserMenu_headerButtons">
|
||||
{/* masked image in CSS */}
|
||||
</span>
|
||||
);
|
||||
let dnd;
|
||||
if (this.state.selectedSpace) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{displayName}</span>
|
||||
<RoomName room={this.state.selectedSpace}>
|
||||
{(roomName) => <span className="mx_UserMenu_subUserName">{roomName}</span>}
|
||||
</RoomName>
|
||||
</div>
|
||||
);
|
||||
} else if (prototypeCommunityName) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{prototypeCommunityName}</span>
|
||||
<span className="mx_UserMenu_subUserName">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
menuName = _t("Community and user menu");
|
||||
isPrototype = true;
|
||||
} else if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{_t("Home")}</span>
|
||||
<span className="mx_UserMenu_subUserName">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
isPrototype = true;
|
||||
} else if (SettingsStore.getValue("feature_dnd")) {
|
||||
const isDnd = SettingsStore.getValue("doNotDisturb");
|
||||
dnd = <AccessibleButton
|
||||
onClick={this.onDndToggle}
|
||||
className={classNames({
|
||||
"mx_UserMenu_dnd": true,
|
||||
"mx_UserMenu_dnd_noisy": !isDnd,
|
||||
"mx_UserMenu_dnd_muted": isDnd,
|
||||
})}
|
||||
/>;
|
||||
}
|
||||
if (this.props.isMinimized) {
|
||||
name = null;
|
||||
buttons = null;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
'mx_UserMenu': true,
|
||||
'mx_UserMenu_minimized': this.props.isMinimized,
|
||||
'mx_UserMenu_prototype': isPrototype,
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className={classes}
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.buttonRef}
|
||||
label={menuName}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<div className="mx_UserMenu_row">
|
||||
<span className="mx_UserMenu_userAvatarContainer">
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
resizeMethod="crop"
|
||||
className="mx_UserMenu_userAvatar"
|
||||
/>
|
||||
</span>
|
||||
{name}
|
||||
{this.state.pendingRoomJoin.size > 0 && (
|
||||
<InlineSpinner>
|
||||
<TooltipButton helpText={_t(
|
||||
"Currently joining %(count)s rooms",
|
||||
{ count: this.state.pendingRoomJoin.size },
|
||||
)} />
|
||||
</InlineSpinner>
|
||||
)}
|
||||
{dnd}
|
||||
{buttons}
|
||||
</div>
|
||||
</ContextMenuButton>
|
||||
{this.renderContextMenu()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,16 @@ limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Matrix from "matrix-js-sdk";
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import * as sdk from "../../index";
|
||||
import Modal from '../../Modal';
|
||||
import { _t } from '../../languageHandler';
|
||||
import HomePage from "./HomePage";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
@replaceableComponent("structures.UserView")
|
||||
export default class UserView extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
@@ -42,14 +46,17 @@ export default class UserView extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.userId !== this.props.userId) {
|
||||
// XXX: We shouldn't need to null check the userId here, but we declare
|
||||
// it as optional and MatrixChat sometimes fires in a way which results
|
||||
// in an NPE when we try to update the profile info.
|
||||
if (prevProps.userId !== this.props.userId && this.props.userId) {
|
||||
this._loadProfileInfo();
|
||||
}
|
||||
}
|
||||
|
||||
async _loadProfileInfo() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
this.setState({loading: true});
|
||||
this.setState({ loading: true });
|
||||
let profileInfo;
|
||||
try {
|
||||
profileInfo = await cli.getProfileInfo(this.props.userId);
|
||||
@@ -59,13 +66,13 @@ export default class UserView extends React.Component {
|
||||
title: _t('Could not load user profile'),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
});
|
||||
this.setState({loading: false});
|
||||
this.setState({ loading: false });
|
||||
return;
|
||||
}
|
||||
const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo});
|
||||
const member = new Matrix.RoomMember(null, this.props.userId);
|
||||
const fakeEvent = new MatrixEvent({ type: "m.room.member", content: profileInfo });
|
||||
const member = new RoomMember(null, this.props.userId);
|
||||
member.setMembershipEvent(fakeEvent);
|
||||
this.setState({member, loading: false});
|
||||
this.setState({ member, loading: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -76,7 +83,9 @@ export default class UserView extends React.Component {
|
||||
const RightPanel = sdk.getComponent('structures.RightPanel');
|
||||
const MainSplit = sdk.getComponent('structures.MainSplit');
|
||||
const panel = <RightPanel user={this.state.member} />;
|
||||
return (<MainSplit panel={panel}><div style={{flex: "1"}} /></MainSplit>);
|
||||
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<HomePage />
|
||||
</MainSplit>);
|
||||
} else {
|
||||
return (<div />);
|
||||
}
|
||||
|
||||
@@ -16,38 +16,177 @@ 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 SyntaxHighlight from '../views/elements/SyntaxHighlight';
|
||||
import {_t} from "../../languageHandler";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import SyntaxHighlight from "../views/elements/SyntaxHighlight";
|
||||
import { _t } from "../../languageHandler";
|
||||
import * as sdk from "../../index";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog";
|
||||
import { canEditContent } from "../../utils/EventUtils";
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ViewSource',
|
||||
|
||||
propTypes: {
|
||||
content: PropTypes.object.isRequired,
|
||||
@replaceableComponent("structures.ViewSource")
|
||||
export default class ViewSource extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
roomId: PropTypes.string.isRequired,
|
||||
eventId: PropTypes.string.isRequired,
|
||||
},
|
||||
mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu
|
||||
};
|
||||
|
||||
render: function() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isEditing: false,
|
||||
};
|
||||
}
|
||||
|
||||
onBack() {
|
||||
// TODO: refresh the "Event ID:" modal header
|
||||
this.setState({ isEditing: false });
|
||||
}
|
||||
|
||||
onEdit() {
|
||||
this.setState({ isEditing: true });
|
||||
}
|
||||
|
||||
// returns the dialog body for viewing the event source
|
||||
viewSourceContent() {
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
const isEncrypted = mxEvent.isEncrypted();
|
||||
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
|
||||
const originalEventSource = mxEvent.event;
|
||||
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<>
|
||||
<details open className="mx_ViewSource_details">
|
||||
<summary>
|
||||
<span className="mx_ViewSource_heading">{_t("Decrypted event source")}</span>
|
||||
</summary>
|
||||
<SyntaxHighlight className="json">{JSON.stringify(decryptedEventSource, null, 2)}</SyntaxHighlight>
|
||||
</details>
|
||||
<details className="mx_ViewSource_details">
|
||||
<summary>
|
||||
<span className="mx_ViewSource_heading">{_t("Original event source")}</span>
|
||||
</summary>
|
||||
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="mx_ViewSource_heading">{_t("Original event source")}</div>
|
||||
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// returns the id of the initial message, not the id of the previous edit
|
||||
getBaseEventId() {
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
const isEncrypted = mxEvent.isEncrypted();
|
||||
const baseMxEvent = this.props.mxEvent;
|
||||
|
||||
if (isEncrypted) {
|
||||
// `relates_to` field is inside the encrypted event
|
||||
return mxEvent.event.content["m.relates_to"]?.event_id ?? baseMxEvent.getId();
|
||||
} else {
|
||||
return mxEvent.getContent()["m.relates_to"]?.event_id ?? baseMxEvent.getId();
|
||||
}
|
||||
}
|
||||
|
||||
// returns the SendCustomEvent component prefilled with the correct details
|
||||
editSourceContent() {
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
|
||||
const isStateEvent = mxEvent.isState();
|
||||
const roomId = mxEvent.getRoomId();
|
||||
const originalContent = mxEvent.getContent();
|
||||
|
||||
if (isStateEvent) {
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
<SendCustomEvent
|
||||
room={cli.getRoom(roomId)}
|
||||
forceStateEvent={true}
|
||||
onBack={() => this.onBack()}
|
||||
inputs={{
|
||||
eventType: mxEvent.getType(),
|
||||
evContent: JSON.stringify(originalContent, null, "\t"),
|
||||
stateKey: mxEvent.getStateKey(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
} else {
|
||||
// prefill an edit-message event
|
||||
// keep only the `body` and `msgtype` fields of originalContent
|
||||
const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there
|
||||
const newContent = {
|
||||
"body": ` * ${bodyToStartFrom}`,
|
||||
"msgtype": originalContent.msgtype,
|
||||
"m.new_content": {
|
||||
body: bodyToStartFrom,
|
||||
msgtype: originalContent.msgtype,
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: "m.replace",
|
||||
event_id: this.getBaseEventId(),
|
||||
},
|
||||
};
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
<SendCustomEvent
|
||||
room={cli.getRoom(roomId)}
|
||||
forceStateEvent={false}
|
||||
forceGeneralEvent={true}
|
||||
onBack={() => this.onBack()}
|
||||
inputs={{
|
||||
eventType: mxEvent.getType(),
|
||||
evContent: JSON.stringify(newContent, null, "\t"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
canSendStateEvent(mxEvent) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(mxEvent.getRoomId());
|
||||
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
|
||||
const isEditing = this.state.isEditing;
|
||||
const roomId = mxEvent.getRoomId();
|
||||
const eventId = mxEvent.getId();
|
||||
const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent);
|
||||
return (
|
||||
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t('View Source')}>
|
||||
<div className="mx_ViewSource_label_left">Room ID: { this.props.roomId }</div>
|
||||
<div className="mx_ViewSource_label_right">Event ID: { this.props.eventId }</div>
|
||||
<div className="mx_ViewSource_label_bottom" />
|
||||
|
||||
<div className="mx_Dialog_content">
|
||||
<SyntaxHighlight className="json">
|
||||
{ JSON.stringify(this.props.content, null, 2) }
|
||||
</SyntaxHighlight>
|
||||
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>
|
||||
<div>
|
||||
<div>Room ID: {roomId}</div>
|
||||
<div>Event ID: {eventId}</div>
|
||||
<div className="mx_ViewSource_separator" />
|
||||
{isEditing ? this.editSourceContent() : this.viewSourceContent()}
|
||||
</div>
|
||||
{!isEditing && canEdit && (
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={() => this.onEdit()}>{_t("Edit")}</button>
|
||||
</div>
|
||||
)}
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,61 +15,62 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import {
|
||||
SetupEncryptionStore,
|
||||
PHASE_INTRO,
|
||||
PHASE_BUSY,
|
||||
PHASE_DONE,
|
||||
PHASE_CONFIRM_SKIP,
|
||||
} from '../../../stores/SetupEncryptionStore';
|
||||
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
|
||||
import SetupEncryptionBody from "./SetupEncryptionBody";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export default class CompleteSecurity extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.CompleteSecurity")
|
||||
export default class CompleteSecurity extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.on("update", this._onStoreUpdate);
|
||||
store.on("update", this.onStoreUpdate);
|
||||
store.start();
|
||||
this.state = {phase: store.phase};
|
||||
this.state = { phase: store.phase };
|
||||
}
|
||||
|
||||
_onStoreUpdate = () => {
|
||||
private onStoreUpdate = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
this.setState({phase: store.phase});
|
||||
this.setState({ phase: store.phase });
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.off("update", this._onStoreUpdate);
|
||||
store.off("update", this.onStoreUpdate);
|
||||
store.stop();
|
||||
}
|
||||
|
||||
render() {
|
||||
public render() {
|
||||
const AuthPage = sdk.getComponent("auth.AuthPage");
|
||||
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
|
||||
const {phase} = this.state;
|
||||
const { phase } = this.state;
|
||||
let icon;
|
||||
let title;
|
||||
|
||||
if (phase === PHASE_INTRO) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
|
||||
title = _t("Complete security");
|
||||
} else if (phase === PHASE_DONE) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified"></span>;
|
||||
if (phase === Phase.Loading) {
|
||||
return null;
|
||||
} else if (phase === Phase.Intro) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Verify this login");
|
||||
} else if (phase === Phase.Done) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
|
||||
title = _t("Session verified");
|
||||
} else if (phase === PHASE_CONFIRM_SKIP) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
|
||||
} else if (phase === Phase.ConfirmSkip) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Are you sure?");
|
||||
} else if (phase === PHASE_BUSY) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
|
||||
title = _t("Complete security");
|
||||
} else if (phase === Phase.Busy) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Verify this login");
|
||||
} else {
|
||||
throw new Error(`Unknown phase ${phase}`);
|
||||
}
|
||||
@@ -15,33 +15,27 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AsyncWrapper from '../../../AsyncWrapper';
|
||||
import * as sdk from '../../../index';
|
||||
import AuthPage from '../../views/auth/AuthPage';
|
||||
import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody';
|
||||
import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export default class E2eSetup extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
accountPassword: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// awkwardly indented because https://github.com/eslint/eslint/issues/11310
|
||||
this._createStorageDialogPromise =
|
||||
import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog");
|
||||
}
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
accountPassword?: string;
|
||||
tokenLogin?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.E2eSetup")
|
||||
export default class E2eSetup extends React.Component<IProps> {
|
||||
render() {
|
||||
const AuthPage = sdk.getComponent("auth.AuthPage");
|
||||
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
|
||||
return (
|
||||
<AuthPage>
|
||||
<CompleteSecurityBody>
|
||||
<AsyncWrapper prom={this._createStorageDialogPromise}
|
||||
hasCancel={false}
|
||||
<CreateCrossSigningDialog
|
||||
onFinished={this.props.onFinished}
|
||||
accountPassword={this.props.accountPassword}
|
||||
tokenLogin={this.props.tokenLogin}
|
||||
/>
|
||||
</CompleteSecurityBody>
|
||||
</AuthPage>
|
||||
@@ -17,109 +17,132 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import Modal from "../../../Modal";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import PasswordReset from "../../../PasswordReset";
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from 'classnames';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import ServerPicker from "../../views/elements/ServerPicker";
|
||||
import PassphraseField from '../../views/auth/PassphraseField';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
// Show the forgot password inputs
|
||||
const PHASE_FORGOT = 1;
|
||||
// Email is in the process of being sent
|
||||
const PHASE_SENDING_EMAIL = 2;
|
||||
// Email has been sent
|
||||
const PHASE_EMAIL_SENT = 3;
|
||||
// User has clicked the link in email and completed reset
|
||||
const PHASE_DONE = 4;
|
||||
import { IValidationResult } from "../../views/elements/Validation";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ForgotPassword',
|
||||
enum Phase {
|
||||
// Show the forgot password inputs
|
||||
Forgot = 1,
|
||||
// Email is in the process of being sent
|
||||
SendingEmail = 2,
|
||||
// Email has been sent
|
||||
EmailSent = 3,
|
||||
// User has clicked the link in email and completed reset
|
||||
Done = 4,
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
onLoginClick: PropTypes.func,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
},
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
onServerConfigChange: (serverConfig: ValidatedServerConfig) => void;
|
||||
onLoginClick?: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
phase: PHASE_FORGOT,
|
||||
email: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
errorText: null,
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
email: string;
|
||||
password: string;
|
||||
password2: string;
|
||||
errorText: string;
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
serverRequiresIdServer: null,
|
||||
};
|
||||
},
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
|
||||
componentDidMount: function() {
|
||||
passwordFieldValid: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.ForgotPassword")
|
||||
export default class ForgotPassword extends React.Component<IProps, IState> {
|
||||
private reset: PasswordReset;
|
||||
|
||||
state = {
|
||||
phase: Phase.Forgot,
|
||||
email: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
errorText: null,
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
passwordFieldValid: false,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.reset = null;
|
||||
this._checkServerLiveliness(this.props.serverConfig);
|
||||
},
|
||||
this.checkServerLiveliness(this.props.serverConfig);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(newProps) {
|
||||
// eslint-disable-next-line camelcase
|
||||
public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Do a liveliness check on the new URLs
|
||||
this._checkServerLiveliness(newProps.serverConfig);
|
||||
},
|
||||
this.checkServerLiveliness(newProps.serverConfig);
|
||||
}
|
||||
|
||||
_checkServerLiveliness: async function(serverConfig) {
|
||||
private async checkServerLiveliness(serverConfig): Promise<void> {
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
serverConfig.hsUrl,
|
||||
serverConfig.isUrl,
|
||||
);
|
||||
|
||||
const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl);
|
||||
const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam();
|
||||
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
serverRequiresIdServer,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
|
||||
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
submitPasswordReset: function(email, password) {
|
||||
public submitPasswordReset(email: string, password: string): void {
|
||||
this.setState({
|
||||
phase: PHASE_SENDING_EMAIL,
|
||||
phase: Phase.SendingEmail,
|
||||
});
|
||||
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
|
||||
this.reset.resetPassword(email, password).then(() => {
|
||||
this.setState({
|
||||
phase: PHASE_EMAIL_SENT,
|
||||
phase: Phase.EmailSent,
|
||||
});
|
||||
}, (err) => {
|
||||
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
|
||||
this.setState({
|
||||
phase: PHASE_FORGOT,
|
||||
phase: Phase.Forgot,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onVerify: async function(ev) {
|
||||
private onVerify = async (ev: React.MouseEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
if (!this.reset) {
|
||||
console.error("onVerify called before submitPasswordReset!");
|
||||
@@ -127,22 +150,26 @@ export default createReactClass({
|
||||
}
|
||||
try {
|
||||
await this.reset.checkEmailLinkClicked();
|
||||
this.setState({ phase: PHASE_DONE });
|
||||
this.setState({ phase: Phase.Done });
|
||||
} catch (err) {
|
||||
this.showErrorDialog(err.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onSubmitForm: async function(ev) {
|
||||
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
|
||||
// refresh the server errors, just in case the server came back online
|
||||
await this._checkServerLiveliness(this.props.serverConfig);
|
||||
await this.checkServerLiveliness(this.props.serverConfig);
|
||||
|
||||
await this['password_field'].validate({ allowEmpty: false });
|
||||
|
||||
if (!this.state.email) {
|
||||
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
|
||||
} else if (!this.state.password || !this.state.password2) {
|
||||
this.showErrorDialog(_t('A new password must be entered.'));
|
||||
} else if (!this.state.passwordFieldValid) {
|
||||
this.showErrorDialog(_t('Please choose a strong password'));
|
||||
} else if (this.state.password !== this.state.password2) {
|
||||
this.showErrorDialog(_t('New passwords must match each other.'));
|
||||
} else {
|
||||
@@ -166,59 +193,33 @@ export default createReactClass({
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onInputChanged: function(stateKey, ev) {
|
||||
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
[stateKey]: ev.target.value,
|
||||
});
|
||||
},
|
||||
[stateKey]: ev.currentTarget.value,
|
||||
} as any);
|
||||
};
|
||||
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
this.setState({
|
||||
phase: PHASE_FORGOT,
|
||||
});
|
||||
},
|
||||
|
||||
onEditServerDetailsClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
},
|
||||
|
||||
onLoginClick: function(ev) {
|
||||
private onLoginClick = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onLoginClick();
|
||||
},
|
||||
};
|
||||
|
||||
showErrorDialog: function(body, title) {
|
||||
public showErrorDialog(description: string, title?: string) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
|
||||
title: title,
|
||||
description: body,
|
||||
title,
|
||||
description,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
renderServerDetails() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={0}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
onAfterSubmit={this.onServerDetailsNextPhaseClick}
|
||||
submitText={_t("Next")}
|
||||
submitClass="mx_Login_submit"
|
||||
/>;
|
||||
},
|
||||
private onPasswordValidate(result: IValidationResult) {
|
||||
this.setState({
|
||||
passwordFieldValid: result.valid,
|
||||
});
|
||||
}
|
||||
|
||||
renderForgot() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
@@ -243,57 +244,13 @@ export default createReactClass({
|
||||
);
|
||||
}
|
||||
|
||||
let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
let editLink = null;
|
||||
if (!SdkConfig.get()['disable_custom_urls']) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) {
|
||||
return <div>
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
{_t(
|
||||
"No identity server is configured: " +
|
||||
"add one in server settings to reset your password.",
|
||||
)}
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{_t('Sign in instead')}
|
||||
</a>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
{errorText}
|
||||
{serverDeadSection}
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
<ServerPicker
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
/>
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
@@ -303,15 +260,23 @@ export default createReactClass({
|
||||
value={this.state.email}
|
||||
onChange={this.onInputChanged.bind(this, "email")}
|
||||
autoFocus
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
<PassphraseField
|
||||
name="reset_password"
|
||||
type="password"
|
||||
label={_t('Password')}
|
||||
label={_td('New Password')}
|
||||
value={this.state.password}
|
||||
minScore={PASSWORD_MIN_SCORE}
|
||||
onChange={this.onInputChanged.bind(this, "password")}
|
||||
fieldRef={field => this['password_field'] = field}
|
||||
onValidate={(result) => this.onPasswordValidate(result)}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Field
|
||||
name="reset_password_confirm"
|
||||
@@ -319,6 +284,9 @@ export default createReactClass({
|
||||
label={_t('Confirm')}
|
||||
value={this.state.password2}
|
||||
onChange={this.onInputChanged.bind(this, "password2")}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<span>{_t(
|
||||
@@ -335,12 +303,12 @@ export default createReactClass({
|
||||
{_t('Sign in instead')}
|
||||
</a>
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
renderSendingEmail() {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
},
|
||||
}
|
||||
|
||||
renderEmailSent() {
|
||||
return <div>
|
||||
@@ -350,7 +318,7 @@ export default createReactClass({
|
||||
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
|
||||
value={_t('I have verified my email address')} />
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
renderDone() {
|
||||
return <div>
|
||||
@@ -363,27 +331,24 @@ export default createReactClass({
|
||||
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
|
||||
value={_t('Return to login screen')} />
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
|
||||
let resetPasswordJsx;
|
||||
switch (this.state.phase) {
|
||||
case PHASE_SERVER_DETAILS:
|
||||
resetPasswordJsx = this.renderServerDetails();
|
||||
break;
|
||||
case PHASE_FORGOT:
|
||||
case Phase.Forgot:
|
||||
resetPasswordJsx = this.renderForgot();
|
||||
break;
|
||||
case PHASE_SENDING_EMAIL:
|
||||
case Phase.SendingEmail:
|
||||
resetPasswordJsx = this.renderSendingEmail();
|
||||
break;
|
||||
case PHASE_EMAIL_SENT:
|
||||
case Phase.EmailSent:
|
||||
resetPasswordJsx = this.renderEmailSent();
|
||||
break;
|
||||
case PHASE_DONE:
|
||||
case Phase.Done:
|
||||
resetPasswordJsx = this.renderDone();
|
||||
break;
|
||||
}
|
||||
@@ -397,5 +362,5 @@ export default createReactClass({
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,680 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 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 from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t, _td} from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import Login from '../../../Login';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import SSOButton from "../../views/elements/SSOButton";
|
||||
import PlatformPeg from '../../../PlatformPeg';
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
// Show the appropriate login flow(s) for the server
|
||||
const PHASE_LOGIN = 1;
|
||||
|
||||
// Enable phases for login
|
||||
const PHASES_ENABLED = true;
|
||||
|
||||
// These are used in several places, and come from the js-sdk's autodiscovery
|
||||
// stuff. We define them here so that they'll be picked up by i18n.
|
||||
_td("Invalid homeserver discovery response");
|
||||
_td("Failed to get autodiscovery configuration from server");
|
||||
_td("Invalid base_url for m.homeserver");
|
||||
_td("Homeserver URL does not appear to be a valid Matrix homeserver");
|
||||
_td("Invalid identity server discovery response");
|
||||
_td("Invalid base_url for m.identity_server");
|
||||
_td("Identity server URL does not appear to be a valid identity server");
|
||||
_td("General failure");
|
||||
|
||||
/**
|
||||
* A wire component which glues together login UI components and Login logic
|
||||
*/
|
||||
export default createReactClass({
|
||||
displayName: 'Login',
|
||||
|
||||
propTypes: {
|
||||
// Called when the user has logged in. Params:
|
||||
// - The object returned by the login API
|
||||
// - The user's password, if applicable, (may be cached in memory for a
|
||||
// short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn: PropTypes.func.isRequired,
|
||||
|
||||
// If true, the component will consider itself busy.
|
||||
busy: PropTypes.bool,
|
||||
|
||||
// Secondary HS which we try to log into if the user is using
|
||||
// the default HS but login fails. Useful for migrating to a
|
||||
// different homeserver without confusing users.
|
||||
fallbackHsUrl: PropTypes.string,
|
||||
|
||||
defaultDeviceDisplayName: PropTypes.string,
|
||||
|
||||
// login shouldn't know or care how registration, password recovery,
|
||||
// etc is done.
|
||||
onRegisterClick: PropTypes.func.isRequired,
|
||||
onForgotPasswordClick: PropTypes.func,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
canTryLogin: true, // can we attempt to log in or are there validation errors?
|
||||
|
||||
// used for preserving form values when changing homeserver
|
||||
username: "",
|
||||
phoneCountry: null,
|
||||
phoneNumber: "",
|
||||
|
||||
// Phase of the overall login dialog.
|
||||
phase: PHASE_LOGIN,
|
||||
// The current login flow, such as password, SSO, etc.
|
||||
currentFlow: null, // we need to load the flows from the server
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
|
||||
// map from login step type to a function which will render a control
|
||||
// letting you do that login type
|
||||
this._stepRendererMap = {
|
||||
'm.login.password': this._renderPasswordStep,
|
||||
|
||||
// CAS and SSO are the same thing, modulo the url we link to
|
||||
'm.login.cas': () => this._renderSsoStep("cas"),
|
||||
'm.login.sso': () => this._renderSsoStep("sso"),
|
||||
};
|
||||
|
||||
this._initLoginLogic();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Ensure that we end up actually logging in to the right place
|
||||
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
|
||||
},
|
||||
|
||||
onPasswordLoginError: function(errorText) {
|
||||
this.setState({
|
||||
errorText,
|
||||
loginIncorrect: Boolean(errorText),
|
||||
});
|
||||
},
|
||||
|
||||
isBusy: function() {
|
||||
return this.state.busy || this.props.busy;
|
||||
},
|
||||
|
||||
onPasswordLogin: async function(username, phoneCountry, phoneNumber, password) {
|
||||
if (!this.state.serverIsAlive) {
|
||||
this.setState({busy: true});
|
||||
// Do a quick liveliness check on the URLs
|
||||
let aliveAgain = true;
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
this.props.serverConfig.hsUrl,
|
||||
this.props.serverConfig.isUrl,
|
||||
);
|
||||
this.setState({serverIsAlive: true, errorText: ""});
|
||||
} catch (e) {
|
||||
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
this.setState({
|
||||
busy: false,
|
||||
...componentState,
|
||||
});
|
||||
aliveAgain = !componentState.serverErrorIsFatal;
|
||||
}
|
||||
|
||||
// Prevent people from submitting their password when something isn't right.
|
||||
if (!aliveAgain) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
this._loginLogic.loginViaPassword(
|
||||
username, phoneCountry, phoneNumber, password,
|
||||
).then((data) => {
|
||||
this.setState({serverIsAlive: true}); // it must be, we logged in.
|
||||
this.props.onLoggedIn(data, password);
|
||||
}, (error) => {
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
let errorText;
|
||||
|
||||
// Some error strings only apply for logging in
|
||||
const usingEmail = username.indexOf("@") > 0;
|
||||
if (error.httpStatus === 400 && usingEmail) {
|
||||
errorText = _t('This homeserver does not support login using email address.');
|
||||
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
error.data.limit_type,
|
||||
error.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"This homeserver has hit its Monthly Active User limit.",
|
||||
),
|
||||
'': _td(
|
||||
"This homeserver has exceeded one of its resource limits.",
|
||||
),
|
||||
});
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
error.data.limit_type,
|
||||
error.data.admin_contact, {
|
||||
'': _td(
|
||||
"Please <a>contact your service administrator</a> to continue using this service.",
|
||||
),
|
||||
});
|
||||
errorText = (
|
||||
<div>
|
||||
<div>{errorTop}</div>
|
||||
<div className="mx_Login_smallError">{errorDetail}</div>
|
||||
</div>
|
||||
);
|
||||
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
|
||||
if (error.errcode === 'M_USER_DEACTIVATED') {
|
||||
errorText = _t('This account has been deactivated.');
|
||||
} else if (SdkConfig.get()['disable_custom_urls']) {
|
||||
errorText = (
|
||||
<div>
|
||||
<div>{ _t('Incorrect username and/or password.') }</div>
|
||||
<div className="mx_Login_smallError">
|
||||
{_t(
|
||||
'Please note you are logging into the %(hs)s server, not matrix.org.',
|
||||
{hs: this.props.serverConfig.hsName},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
errorText = _t('Incorrect username and/or password.');
|
||||
}
|
||||
} else {
|
||||
// other errors, not specific to doing a password login
|
||||
errorText = this._errorTextFromError(error);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: errorText,
|
||||
// 401 would be the sensible status code for 'incorrect password'
|
||||
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
|
||||
// mentions this (although the bug is for UI auth which is not this)
|
||||
// We treat both as an incorrect password
|
||||
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
onUsernameChanged: function(username) {
|
||||
this.setState({ username: username });
|
||||
},
|
||||
|
||||
onUsernameBlur: async function(username) {
|
||||
const doWellknownLookup = username[0] === "@";
|
||||
this.setState({
|
||||
username: username,
|
||||
busy: doWellknownLookup,
|
||||
errorText: null,
|
||||
canTryLogin: true,
|
||||
});
|
||||
if (doWellknownLookup) {
|
||||
const serverName = username.split(':').slice(1).join(':');
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerName(serverName);
|
||||
this.props.onServerConfigChange(result);
|
||||
// We'd like to rely on new props coming in via `onServerConfigChange`
|
||||
// so that we know the servers have definitely updated before clearing
|
||||
// the busy state. In the case of a full MXID that resolves to the same
|
||||
// HS as Riot's default HS though, there may not be any server change.
|
||||
// To avoid this trap, we clear busy here. For cases where the server
|
||||
// actually has changed, `_initLoginLogic` will be called and manages
|
||||
// busy state for its own liveness check.
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
|
||||
|
||||
let message = _t("Failed to perform homeserver discovery");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
|
||||
let errorText = message;
|
||||
let discoveryState = {};
|
||||
if (AutoDiscoveryUtils.isLivelinessError(e)) {
|
||||
errorText = this.state.errorText;
|
||||
discoveryState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText,
|
||||
...discoveryState,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onPhoneCountryChanged: function(phoneCountry) {
|
||||
this.setState({ phoneCountry: phoneCountry });
|
||||
},
|
||||
|
||||
onPhoneNumberChanged: function(phoneNumber) {
|
||||
this.setState({
|
||||
phoneNumber: phoneNumber,
|
||||
});
|
||||
},
|
||||
|
||||
onPhoneNumberBlur: function(phoneNumber) {
|
||||
// Validate the phone number entered
|
||||
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
|
||||
this.setState({
|
||||
errorText: _t('The phone number entered looks invalid'),
|
||||
canTryLogin: false,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
canTryLogin: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onRegisterClick: function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onRegisterClick();
|
||||
},
|
||||
|
||||
onTryRegisterClick: function(ev) {
|
||||
const step = this._getCurrentFlowStep();
|
||||
if (step === 'm.login.sso' || step === 'm.login.cas') {
|
||||
// If we're showing SSO it means that registration is also probably disabled,
|
||||
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
|
||||
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind);
|
||||
} else {
|
||||
// Don't intercept - just go through to the register page
|
||||
this.onRegisterClick(ev);
|
||||
}
|
||||
},
|
||||
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
this.setState({
|
||||
phase: PHASE_LOGIN,
|
||||
});
|
||||
},
|
||||
|
||||
onEditServerDetailsClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
},
|
||||
|
||||
_initLoginLogic: async function(hsUrl, isUrl) {
|
||||
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
|
||||
isUrl = isUrl || this.props.serverConfig.isUrl;
|
||||
|
||||
let isDefaultServer = false;
|
||||
if (this.props.serverConfig.isDefault
|
||||
&& hsUrl === this.props.serverConfig.hsUrl
|
||||
&& isUrl === this.props.serverConfig.isUrl) {
|
||||
isDefaultServer = true;
|
||||
}
|
||||
|
||||
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl : null;
|
||||
|
||||
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
|
||||
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
||||
});
|
||||
this._loginLogic = loginLogic;
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
currentFlow: null, // reset flow
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
// Do a quick liveliness check on the URLs
|
||||
try {
|
||||
const { warning } =
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
if (warning) {
|
||||
this.setState({
|
||||
...AutoDiscoveryUtils.authComponentStateForError(warning),
|
||||
errorText: "",
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
errorText: "",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e),
|
||||
});
|
||||
if (this.state.serverErrorIsFatal) {
|
||||
// Server is dead: show server details prompt instead
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loginLogic.getFlows().then((flows) => {
|
||||
// look for a flow where we understand all of the steps.
|
||||
for (let i = 0; i < flows.length; i++ ) {
|
||||
if (!this._isSupportedFlow(flows[i])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// we just pick the first flow where we support all the
|
||||
// steps. (we don't have a UI for multiple logins so let's skip
|
||||
// that for now).
|
||||
loginLogic.chooseFlow(i);
|
||||
this.setState({
|
||||
currentFlow: this._getCurrentFlowStep(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// we got to the end of the list without finding a suitable
|
||||
// flow.
|
||||
this.setState({
|
||||
errorText: _t(
|
||||
"This homeserver doesn't offer any login flows which are " +
|
||||
"supported by this client.",
|
||||
),
|
||||
});
|
||||
}, (err) => {
|
||||
this.setState({
|
||||
errorText: this._errorTextFromError(err),
|
||||
loginIncorrect: false,
|
||||
canTryLogin: false,
|
||||
});
|
||||
}).finally(() => {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_isSupportedFlow: function(flow) {
|
||||
// technically the flow can have multiple steps, but no one does this
|
||||
// for login and loginLogic doesn't support it so we can ignore it.
|
||||
if (!this._stepRendererMap[flow.type]) {
|
||||
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
_getCurrentFlowStep: function() {
|
||||
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
|
||||
},
|
||||
|
||||
_errorTextFromError(err) {
|
||||
let errCode = err.errcode;
|
||||
if (!errCode && err.httpStatus) {
|
||||
errCode = "HTTP " + err.httpStatus;
|
||||
}
|
||||
|
||||
let errorText = _t("Error: Problem communicating with the given homeserver.") +
|
||||
(errCode ? " (" + errCode + ")" : "");
|
||||
|
||||
if (err.cors === 'rejected') {
|
||||
if (window.location.protocol === 'https:' &&
|
||||
(this.props.serverConfig.hsUrl.startsWith("http:") ||
|
||||
!this.props.serverConfig.hsUrl.startsWith("http"))
|
||||
) {
|
||||
errorText = <span>
|
||||
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
|
||||
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener"
|
||||
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
|
||||
>
|
||||
{ sub }
|
||||
</a>;
|
||||
},
|
||||
},
|
||||
) }
|
||||
</span>;
|
||||
} else {
|
||||
errorText = <span>
|
||||
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
|
||||
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
|
||||
"is not blocking requests.", {},
|
||||
{
|
||||
'a': (sub) =>
|
||||
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
) }
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
|
||||
return errorText;
|
||||
},
|
||||
|
||||
renderServerComponent() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverDetailsProps = {};
|
||||
if (PHASES_ENABLED) {
|
||||
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
|
||||
serverDetailsProps.submitText = _t("Next");
|
||||
serverDetailsProps.submitClass = "mx_Login_submit";
|
||||
}
|
||||
|
||||
return <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderLoginComponentForStep() {
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = this.state.currentFlow;
|
||||
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stepRenderer = this._stepRendererMap[step];
|
||||
|
||||
if (stepRenderer) {
|
||||
return stepRenderer();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
_renderPasswordStep: function() {
|
||||
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
|
||||
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
|
||||
return (
|
||||
<PasswordLogin
|
||||
onSubmit={this.onPasswordLogin}
|
||||
onError={this.onPasswordLoginError}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick}
|
||||
initialUsername={this.state.username}
|
||||
initialPhoneCountry={this.state.phoneCountry}
|
||||
initialPhoneNumber={this.state.phoneNumber}
|
||||
onUsernameChanged={this.onUsernameChanged}
|
||||
onUsernameBlur={this.onUsernameBlur}
|
||||
onPhoneCountryChanged={this.onPhoneCountryChanged}
|
||||
onPhoneNumberChanged={this.onPhoneNumberChanged}
|
||||
onPhoneNumberBlur={this.onPhoneNumberBlur}
|
||||
onForgotPasswordClick={this.props.onForgotPasswordClick}
|
||||
loginIncorrect={this.state.loginIncorrect}
|
||||
serverConfig={this.props.serverConfig}
|
||||
disableSubmit={this.isBusy()}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
_renderSsoStep: function(loginType) {
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
|
||||
// redirecting the user back to a URI once they're logged in. On the web, this means
|
||||
// we use the same window and redirect back to riot. On electron, this actually
|
||||
// opens the SSO page in the electron app itself due to
|
||||
// https://github.com/electron/electron/issues/8841 and so happens to work.
|
||||
// If this bug gets fixed, it will break SSO since it will open the SSO page in the
|
||||
// user's browser, let them log into their SSO provider, then redirect their browser
|
||||
// to vector://vector which, of course, will not work.
|
||||
return (
|
||||
<div>
|
||||
<SignInToText serverConfig={this.props.serverConfig}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick} />
|
||||
|
||||
<SSOButton
|
||||
className="mx_Login_sso_link mx_Login_submit"
|
||||
matrixClient={this._loginLogic.createTemporaryClient()}
|
||||
loginType={loginType} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const loader = this.isBusy() ? <div className="mx_Login_loader"><Loader /></div> : null;
|
||||
|
||||
const errorText = this.state.errorText;
|
||||
|
||||
let errorTextSection;
|
||||
if (errorText) {
|
||||
errorTextSection = (
|
||||
<div className="mx_Login_error">
|
||||
{ errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
"mx_Login_error": true,
|
||||
"mx_Login_serverError": true,
|
||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<h2>
|
||||
{_t('Sign in')}
|
||||
{loader}
|
||||
</h2>
|
||||
{ errorTextSection }
|
||||
{ serverDeadSection }
|
||||
{ this.renderServerComponent() }
|
||||
{ this.renderLoginComponentForStep() }
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onTryRegisterClick} href="#">
|
||||
{ _t('Create account') }
|
||||
</a>
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
614
src/components/structures/auth/Login.tsx
Normal file
614
src/components/structures/auth/Login.tsx
Normal file
@@ -0,0 +1,614 @@
|
||||
/*
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import Login, { ISSOFlow, LoginFlow } from '../../../Login';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import PlatformPeg from '../../../PlatformPeg';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import { IMatrixClientCreds } from "../../../MatrixClientPeg";
|
||||
import PasswordLogin from "../../views/auth/PasswordLogin";
|
||||
import InlineSpinner from "../../views/elements/InlineSpinner";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from "../../views/elements/ServerPicker";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AuthBody from "../../views/auth/AuthBody";
|
||||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
|
||||
// These are used in several places, and come from the js-sdk's autodiscovery
|
||||
// stuff. We define them here so that they'll be picked up by i18n.
|
||||
_td("Invalid homeserver discovery response");
|
||||
_td("Failed to get autodiscovery configuration from server");
|
||||
_td("Invalid base_url for m.homeserver");
|
||||
_td("Homeserver URL does not appear to be a valid Matrix homeserver");
|
||||
_td("Invalid identity server discovery response");
|
||||
_td("Invalid base_url for m.identity_server");
|
||||
_td("Identity server URL does not appear to be a valid identity server");
|
||||
_td("General failure");
|
||||
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
// If true, the component will consider itself busy.
|
||||
busy?: boolean;
|
||||
isSyncing?: boolean;
|
||||
// Secondary HS which we try to log into if the user is using
|
||||
// the default HS but login fails. Useful for migrating to a
|
||||
// different homeserver without confusing users.
|
||||
fallbackHsUrl?: string;
|
||||
defaultDeviceDisplayName?: string;
|
||||
fragmentAfterLogin?: string;
|
||||
defaultUsername?: string;
|
||||
|
||||
// Called when the user has logged in. Params:
|
||||
// - The object returned by the login API
|
||||
// - The user's password, if applicable, (may be cached in memory for a
|
||||
// short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn(data: IMatrixClientCreds, password: string): void;
|
||||
|
||||
// login shouldn't know or care how registration, password recovery, etc is done.
|
||||
onRegisterClick(): void;
|
||||
onForgotPasswordClick?(): void;
|
||||
onServerConfigChange(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
busy: boolean;
|
||||
busyLoggingIn?: boolean;
|
||||
errorText?: ReactNode;
|
||||
loginIncorrect: boolean;
|
||||
// can we attempt to log in or are there validation errors?
|
||||
canTryLogin: boolean;
|
||||
|
||||
flows?: LoginFlow[];
|
||||
|
||||
// used for preserving form values when changing homeserver
|
||||
username: string;
|
||||
phoneCountry?: string;
|
||||
phoneNumber: string;
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError?: ReactNode;
|
||||
}
|
||||
|
||||
/*
|
||||
* A wire component which glues together login UI components and Login logic
|
||||
*/
|
||||
@replaceableComponent("structures.auth.LoginComponent")
|
||||
export default class LoginComponent extends React.PureComponent<IProps, IState> {
|
||||
private unmounted = false;
|
||||
private loginLogic: Login;
|
||||
|
||||
private readonly stepRendererMap: Record<string, () => ReactNode>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
busyLoggingIn: null,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
canTryLogin: true,
|
||||
|
||||
flows: null,
|
||||
|
||||
username: props.defaultUsername? props.defaultUsername: '',
|
||||
phoneCountry: null,
|
||||
phoneNumber: "",
|
||||
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
};
|
||||
|
||||
// map from login step type to a function which will render a control
|
||||
// letting you do that login type
|
||||
this.stepRendererMap = {
|
||||
'm.login.password': this.renderPasswordStep,
|
||||
|
||||
// CAS and SSO are the same thing, modulo the url we link to
|
||||
'm.login.cas': () => this.renderSsoStep("cas"),
|
||||
'm.login.sso': () => this.renderSsoStep("sso"),
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_login_begin");
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillMount() {
|
||||
this.initLoginLogic(this.props.serverConfig);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Ensure that we end up actually logging in to the right place
|
||||
this.initLoginLogic(newProps.serverConfig);
|
||||
}
|
||||
|
||||
isBusy = () => this.state.busy || this.props.busy;
|
||||
|
||||
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
|
||||
if (!this.state.serverIsAlive) {
|
||||
this.setState({ busy: true });
|
||||
// Do a quick liveliness check on the URLs
|
||||
let aliveAgain = true;
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
this.props.serverConfig.hsUrl,
|
||||
this.props.serverConfig.isUrl,
|
||||
);
|
||||
this.setState({ serverIsAlive: true, errorText: "" });
|
||||
} catch (e) {
|
||||
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
this.setState({
|
||||
busy: false,
|
||||
busyLoggingIn: false,
|
||||
...componentState,
|
||||
});
|
||||
aliveAgain = !componentState.serverErrorIsFatal;
|
||||
}
|
||||
|
||||
// Prevent people from submitting their password when something isn't right.
|
||||
if (!aliveAgain) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
busyLoggingIn: true,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
this.loginLogic.loginViaPassword(
|
||||
username, phoneCountry, phoneNumber, password,
|
||||
).then((data) => {
|
||||
this.setState({ serverIsAlive: true }); // it must be, we logged in.
|
||||
this.props.onLoggedIn(data, password);
|
||||
}, (error) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
let errorText;
|
||||
|
||||
// Some error strings only apply for logging in
|
||||
const usingEmail = username.indexOf("@") > 0;
|
||||
if (error.httpStatus === 400 && usingEmail) {
|
||||
errorText = _t('This homeserver does not support login using email address.');
|
||||
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
error.data.limit_type,
|
||||
error.data.admin_contact,
|
||||
{
|
||||
'monthly_active_user': _td(
|
||||
"This homeserver has hit its Monthly Active User limit.",
|
||||
),
|
||||
'hs_blocked': _td(
|
||||
"This homeserver has been blocked by it's administrator.",
|
||||
),
|
||||
'': _td(
|
||||
"This homeserver has exceeded one of its resource limits.",
|
||||
),
|
||||
},
|
||||
);
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
error.data.limit_type,
|
||||
error.data.admin_contact,
|
||||
{
|
||||
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
|
||||
},
|
||||
);
|
||||
errorText = (
|
||||
<div>
|
||||
<div>{errorTop}</div>
|
||||
<div className="mx_Login_smallError">{errorDetail}</div>
|
||||
</div>
|
||||
);
|
||||
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
|
||||
if (error.errcode === 'M_USER_DEACTIVATED') {
|
||||
errorText = _t('This account has been deactivated.');
|
||||
} else if (SdkConfig.get()['disable_custom_urls']) {
|
||||
errorText = (
|
||||
<div>
|
||||
<div>{ _t('Incorrect username and/or password.') }</div>
|
||||
<div className="mx_Login_smallError">
|
||||
{_t(
|
||||
'Please note you are logging into the %(hs)s server, not matrix.org.',
|
||||
{ hs: this.props.serverConfig.hsName },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
errorText = _t('Incorrect username and/or password.');
|
||||
}
|
||||
} else {
|
||||
// other errors, not specific to doing a password login
|
||||
errorText = this.errorTextFromError(error);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
busyLoggingIn: false,
|
||||
errorText: errorText,
|
||||
// 401 would be the sensible status code for 'incorrect password'
|
||||
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
|
||||
// mentions this (although the bug is for UI auth which is not this)
|
||||
// We treat both as an incorrect password
|
||||
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onUsernameChanged = username => {
|
||||
this.setState({ username: username });
|
||||
};
|
||||
|
||||
onUsernameBlur = async username => {
|
||||
const doWellknownLookup = username[0] === "@";
|
||||
this.setState({
|
||||
username: username,
|
||||
busy: doWellknownLookup,
|
||||
errorText: null,
|
||||
canTryLogin: true,
|
||||
});
|
||||
if (doWellknownLookup) {
|
||||
const serverName = username.split(':').slice(1).join(':');
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerName(serverName);
|
||||
this.props.onServerConfigChange(result);
|
||||
// We'd like to rely on new props coming in via `onServerConfigChange`
|
||||
// so that we know the servers have definitely updated before clearing
|
||||
// the busy state. In the case of a full MXID that resolves to the same
|
||||
// HS as Element's default HS though, there may not be any server change.
|
||||
// To avoid this trap, we clear busy here. For cases where the server
|
||||
// actually has changed, `initLoginLogic` will be called and manages
|
||||
// busy state for its own liveness check.
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
|
||||
|
||||
let message = _t("Failed to perform homeserver discovery");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
|
||||
let errorText: ReactNode = message;
|
||||
let discoveryState = {};
|
||||
if (AutoDiscoveryUtils.isLivelinessError(e)) {
|
||||
errorText = this.state.errorText;
|
||||
discoveryState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText,
|
||||
...discoveryState,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onPhoneCountryChanged = phoneCountry => {
|
||||
this.setState({ phoneCountry: phoneCountry });
|
||||
};
|
||||
|
||||
onPhoneNumberChanged = phoneNumber => {
|
||||
this.setState({
|
||||
phoneNumber: phoneNumber,
|
||||
});
|
||||
};
|
||||
|
||||
onRegisterClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onRegisterClick();
|
||||
};
|
||||
|
||||
onTryRegisterClick = ev => {
|
||||
const hasPasswordFlow = this.state.flows?.find(flow => flow.type === "m.login.password");
|
||||
const ssoFlow = this.state.flows?.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
|
||||
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
|
||||
// TODO: instead hide the Register button if registration is disabled by checking with the server,
|
||||
// has no specific errCode currently and uses M_FORBIDDEN.
|
||||
if (ssoFlow && !hasPasswordFlow) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas';
|
||||
PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
|
||||
this.props.fragmentAfterLogin);
|
||||
} else {
|
||||
// Don't intercept - just go through to the register page
|
||||
this.onRegisterClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig) {
|
||||
let isDefaultServer = false;
|
||||
if (this.props.serverConfig.isDefault
|
||||
&& hsUrl === this.props.serverConfig.hsUrl
|
||||
&& isUrl === this.props.serverConfig.isUrl) {
|
||||
isDefaultServer = true;
|
||||
}
|
||||
|
||||
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl : null;
|
||||
|
||||
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
|
||||
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
||||
});
|
||||
this.loginLogic = loginLogic;
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
// Do a quick liveliness check on the URLs
|
||||
try {
|
||||
const { warning } =
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
if (warning) {
|
||||
this.setState({
|
||||
...AutoDiscoveryUtils.authComponentStateForError(warning),
|
||||
errorText: "",
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
errorText: "",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e),
|
||||
});
|
||||
}
|
||||
|
||||
loginLogic.getFlows().then((flows) => {
|
||||
// look for a flow where we understand all of the steps.
|
||||
const supportedFlows = flows.filter(this.isSupportedFlow);
|
||||
|
||||
if (supportedFlows.length > 0) {
|
||||
this.setState({
|
||||
flows: supportedFlows,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// we got to the end of the list without finding a suitable flow.
|
||||
this.setState({
|
||||
errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."),
|
||||
});
|
||||
}, (err) => {
|
||||
this.setState({
|
||||
errorText: this.errorTextFromError(err),
|
||||
loginIncorrect: false,
|
||||
canTryLogin: false,
|
||||
});
|
||||
}).finally(() => {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isSupportedFlow = (flow: LoginFlow): boolean => {
|
||||
// technically the flow can have multiple steps, but no one does this
|
||||
// for login and loginLogic doesn't support it so we can ignore it.
|
||||
if (!this.stepRendererMap[flow.type]) {
|
||||
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private errorTextFromError(err: MatrixError): ReactNode {
|
||||
let errCode = err.errcode;
|
||||
if (!errCode && err.httpStatus) {
|
||||
errCode = "HTTP " + err.httpStatus;
|
||||
}
|
||||
|
||||
let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " +
|
||||
"please try again later.") + (errCode ? " (" + errCode + ")" : "");
|
||||
|
||||
if (err.cors === 'rejected') {
|
||||
if (window.location.protocol === 'https:' &&
|
||||
(this.props.serverConfig.hsUrl.startsWith("http:") ||
|
||||
!this.props.serverConfig.hsUrl.startsWith("http"))
|
||||
) {
|
||||
errorText = <span>
|
||||
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
|
||||
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener"
|
||||
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
|
||||
>
|
||||
{ sub }
|
||||
</a>;
|
||||
},
|
||||
}) }
|
||||
</span>;
|
||||
} else {
|
||||
errorText = <span>
|
||||
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
|
||||
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
|
||||
"is not blocking requests.", {},
|
||||
{
|
||||
'a': (sub) =>
|
||||
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
|
||||
{ sub }
|
||||
</a>,
|
||||
}) }
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
|
||||
return errorText;
|
||||
}
|
||||
|
||||
renderLoginComponentForFlows() {
|
||||
if (!this.state.flows) return null;
|
||||
|
||||
// this is the ideal order we want to show the flows in
|
||||
const order = [
|
||||
"m.login.password",
|
||||
"m.login.sso",
|
||||
];
|
||||
|
||||
const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean);
|
||||
return <React.Fragment>
|
||||
{ flows.map(flow => {
|
||||
const stepRenderer = this.stepRendererMap[flow.type];
|
||||
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>;
|
||||
}) }
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
private renderPasswordStep = () => {
|
||||
return (
|
||||
<PasswordLogin
|
||||
onSubmit={this.onPasswordLogin}
|
||||
username={this.state.username}
|
||||
phoneCountry={this.state.phoneCountry}
|
||||
phoneNumber={this.state.phoneNumber}
|
||||
onUsernameChanged={this.onUsernameChanged}
|
||||
onUsernameBlur={this.onUsernameBlur}
|
||||
onPhoneCountryChanged={this.onPhoneCountryChanged}
|
||||
onPhoneNumberChanged={this.onPhoneNumberChanged}
|
||||
onForgotPasswordClick={this.props.onForgotPasswordClick}
|
||||
loginIncorrect={this.state.loginIncorrect}
|
||||
serverConfig={this.props.serverConfig}
|
||||
disableSubmit={this.isBusy()}
|
||||
busy={this.props.isSyncing || this.state.busyLoggingIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderSsoStep = loginType => {
|
||||
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
|
||||
|
||||
return (
|
||||
<SSOButtons
|
||||
matrixClient={this.loginLogic.createTemporaryClient()}
|
||||
flow={flow}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const loader = this.isBusy() && !this.state.busyLoggingIn ?
|
||||
<div className="mx_Login_loader"><Spinner /></div> : null;
|
||||
|
||||
const errorText = this.state.errorText;
|
||||
|
||||
let errorTextSection;
|
||||
if (errorText) {
|
||||
errorTextSection = (
|
||||
<div className="mx_Login_error">
|
||||
{ errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
"mx_Login_error": true,
|
||||
"mx_Login_serverError": true,
|
||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let footer;
|
||||
if (this.props.isSyncing || this.state.busyLoggingIn) {
|
||||
footer = <div className="mx_AuthBody_paddedFooter">
|
||||
<div className="mx_AuthBody_paddedFooter_title">
|
||||
<InlineSpinner w={20} h={20} />
|
||||
{ this.props.isSyncing ? _t("Syncing...") : _t("Signing In...") }
|
||||
</div>
|
||||
{ this.props.isSyncing && <div className="mx_AuthBody_paddedFooter_subtitle">
|
||||
{_t("If you've joined lots of rooms, this might take a while")}
|
||||
</div> }
|
||||
</div>;
|
||||
} else if (SettingsStore.getValue(UIFeature.Registration)) {
|
||||
footer = (
|
||||
<span className="mx_AuthBody_changeFlow">
|
||||
{_t("New? <a>Create account</a>", {}, {
|
||||
a: sub => <a onClick={this.onTryRegisterClick} href="#">{ sub }</a>,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
|
||||
<AuthBody>
|
||||
<h2>
|
||||
{_t('Sign in')}
|
||||
{loader}
|
||||
</h2>
|
||||
{ errorTextSection }
|
||||
{ serverDeadSection }
|
||||
<ServerPicker
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
/>
|
||||
{ this.renderLoginComponentForFlows() }
|
||||
{ footer }
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket 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 createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'PostRegistration',
|
||||
|
||||
propTypes: {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
avatarUrl: null,
|
||||
errorString: null,
|
||||
busy: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
|
||||
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
|
||||
// the URL to be passed to you (because it's also used for room avatars).
|
||||
const cli = MatrixClientPeg.get();
|
||||
this.setState({busy: true});
|
||||
const self = this;
|
||||
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
|
||||
self.setState({
|
||||
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
|
||||
busy: false,
|
||||
});
|
||||
}, function(error) {
|
||||
self.setState({
|
||||
errorString: _t("Failed to fetch avatar URL"),
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
|
||||
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
|
||||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<div className="mx_Login_profile">
|
||||
{ _t('Set a display name:') }
|
||||
<ChangeDisplayName />
|
||||
{ _t('Upload an avatar:') }
|
||||
<ChangeAvatar
|
||||
initialAvatarUrl={this.state.avatarUrl} />
|
||||
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
|
||||
{ this.state.errorString }
|
||||
</div>
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,704 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
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 Matrix from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import Login from "../../../Login";
|
||||
import dis from "../../../dispatcher";
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
// Show the appropriate registration flow(s) for the server
|
||||
const PHASE_REGISTRATION = 1;
|
||||
|
||||
// Enable phases for registration
|
||||
const PHASES_ENABLED = true;
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'Registration',
|
||||
|
||||
propTypes: {
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
// for a short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn: PropTypes.func.isRequired,
|
||||
|
||||
clientSecret: PropTypes.string,
|
||||
sessionId: PropTypes.string,
|
||||
makeRegistrationUrl: PropTypes.func.isRequired,
|
||||
idSid: PropTypes.string,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
brand: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick: PropTypes.func.isRequired,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
defaultDeviceDisplayName: PropTypes.string,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
// We remember the values entered by the user because
|
||||
// the registration form will be unmounted during the
|
||||
// course of registration, but if there's an error we
|
||||
// want to bring back the registration form with the
|
||||
// values the user entered still in it. We can keep
|
||||
// them in this component's state since this component
|
||||
// persist for the duration of the registration process.
|
||||
formVals: {
|
||||
email: this.props.email,
|
||||
},
|
||||
// true if we're waiting for the user to complete
|
||||
// user-interactive auth
|
||||
// If we've been given a session ID, we're resuming
|
||||
// straight back into UI auth
|
||||
doingUIAuth: Boolean(this.props.sessionId),
|
||||
serverType,
|
||||
// Phase of the overall registration dialog.
|
||||
phase: PHASE_REGISTRATION,
|
||||
flows: null,
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: false,
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient: null,
|
||||
|
||||
// whether the HS requires an ID server to register with a threepid
|
||||
serverRequiresIdServer: null,
|
||||
|
||||
// The user ID we've just registered
|
||||
registeredUsername: null,
|
||||
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId: null,
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this._unmounted = false;
|
||||
this._replaceClient();
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
this._replaceClient(newProps.serverConfig);
|
||||
|
||||
// Handle cases where the user enters "https://matrix.org" for their server
|
||||
// from the advanced option - we should default to FREE at that point.
|
||||
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
|
||||
if (serverType !== this.state.serverType) {
|
||||
// Reset the phase to default phase for the server type.
|
||||
this.setState({
|
||||
serverType,
|
||||
phase: this.getDefaultPhaseForServerType(serverType),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getDefaultPhaseForServerType(type) {
|
||||
switch (type) {
|
||||
case ServerType.FREE: {
|
||||
// Move directly to the registration phase since the server
|
||||
// details are fixed.
|
||||
return PHASE_REGISTRATION;
|
||||
}
|
||||
case ServerType.PREMIUM:
|
||||
case ServerType.ADVANCED:
|
||||
return PHASE_SERVER_DETAILS;
|
||||
}
|
||||
},
|
||||
|
||||
onServerTypeChange(type) {
|
||||
this.setState({
|
||||
serverType: type,
|
||||
});
|
||||
|
||||
// When changing server types, set the HS / IS URLs to reasonable defaults for the
|
||||
// the new type.
|
||||
switch (type) {
|
||||
case ServerType.FREE: {
|
||||
const { serverConfig } = ServerType.TYPES.FREE;
|
||||
this.props.onServerConfigChange(serverConfig);
|
||||
break;
|
||||
}
|
||||
case ServerType.PREMIUM:
|
||||
// We can accept whatever server config was the default here as this essentially
|
||||
// acts as a slightly different "custom server"/ADVANCED option.
|
||||
break;
|
||||
case ServerType.ADVANCED:
|
||||
// Use the default config from the config
|
||||
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Reset the phase to default phase for the server type.
|
||||
this.setState({
|
||||
phase: this.getDefaultPhaseForServerType(type),
|
||||
});
|
||||
},
|
||||
|
||||
_replaceClient: async function(serverConfig) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
serverDeadError: null,
|
||||
serverErrorIsFatal: false,
|
||||
// busy while we do liveness check (we need to avoid trying to render
|
||||
// the UI auth component while we don't have a matrix client)
|
||||
busy: true,
|
||||
});
|
||||
if (!serverConfig) serverConfig = this.props.serverConfig;
|
||||
|
||||
// Do a liveliness check on the URLs
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
serverConfig.hsUrl,
|
||||
serverConfig.isUrl,
|
||||
);
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e, "register"),
|
||||
});
|
||||
if (this.state.serverErrorIsFatal) {
|
||||
return; // Server is dead - do not continue.
|
||||
}
|
||||
}
|
||||
|
||||
const {hsUrl, isUrl} = serverConfig;
|
||||
const cli = Matrix.createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
});
|
||||
|
||||
let serverRequiresIdServer = true;
|
||||
try {
|
||||
serverRequiresIdServer = await cli.doesServerRequireIdServerParam();
|
||||
} catch (e) {
|
||||
console.log("Unable to determine is server needs id_server param", e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
matrixClient: cli,
|
||||
serverRequiresIdServer,
|
||||
busy: false,
|
||||
});
|
||||
const showGenericError = (e) => {
|
||||
this.setState({
|
||||
errorText: _t("Unable to query for supported registration methods."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
};
|
||||
try {
|
||||
await this._makeRegisterRequest({});
|
||||
// This should never succeed since we specified an empty
|
||||
// auth object.
|
||||
console.log("Expecting 401 from register request but got success!");
|
||||
} catch (e) {
|
||||
if (e.httpStatus === 401) {
|
||||
this.setState({
|
||||
flows: e.data.flows,
|
||||
});
|
||||
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") {
|
||||
// At this point registration is pretty much disabled, but before we do that let's
|
||||
// quickly check to see if the server supports SSO instead. If it does, we'll send
|
||||
// the user off to the login page to figure their account out.
|
||||
try {
|
||||
const loginLogic = new Login(hsUrl, isUrl, null, {
|
||||
defaultDeviceDisplayName: "riot login check", // We shouldn't ever be used
|
||||
});
|
||||
const flows = await loginLogic.getFlows();
|
||||
const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas');
|
||||
if (hasSsoFlow) {
|
||||
// Redirect to login page - server probably expects SSO only
|
||||
dis.dispatch({action: 'start_login'});
|
||||
} else {
|
||||
this.setState({
|
||||
errorText: _t("Registration has been disabled on this homeserver."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get login flows to check for SSO support", e);
|
||||
showGenericError(e);
|
||||
}
|
||||
} else {
|
||||
console.log("Unable to query for supported registration methods.", e);
|
||||
showGenericError(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onFormSubmit: function(formVals) {
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
formVals: formVals,
|
||||
doingUIAuth: true,
|
||||
});
|
||||
},
|
||||
|
||||
_requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) {
|
||||
return this.state.matrixClient.requestRegisterEmailToken(
|
||||
emailAddress,
|
||||
clientSecret,
|
||||
sendAttempt,
|
||||
this.props.makeRegistrationUrl({
|
||||
client_secret: clientSecret,
|
||||
hs_url: this.state.matrixClient.getHomeserverUrl(),
|
||||
is_url: this.state.matrixClient.getIdentityServerUrl(),
|
||||
session_id: sessionId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
_onUIAuthFinished: async function(success, response, extra) {
|
||||
if (!success) {
|
||||
let msg = response.message || response.toString();
|
||||
// can we give a better error message?
|
||||
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"This homeserver has hit its Monthly Active User limit.",
|
||||
),
|
||||
'': _td(
|
||||
"This homeserver has exceeded one of its resource limits.",
|
||||
),
|
||||
});
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact, {
|
||||
'': _td(
|
||||
"Please <a>contact your service administrator</a> to continue using this service.",
|
||||
),
|
||||
});
|
||||
msg = <div>
|
||||
<p>{errorTop}</p>
|
||||
<p>{errorDetail}</p>
|
||||
</div>;
|
||||
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
|
||||
let msisdnAvailable = false;
|
||||
for (const flow of response.available_flows) {
|
||||
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
|
||||
}
|
||||
if (!msisdnAvailable) {
|
||||
msg = _t('This server does not support authentication with a phone number.');
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
errorText: msg,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
|
||||
|
||||
const newState = {
|
||||
doingUIAuth: false,
|
||||
registeredUsername: response.user_id,
|
||||
};
|
||||
|
||||
// The user came in through an email validation link. To avoid overwriting
|
||||
// their session, check to make sure the session isn't someone else, and
|
||||
// isn't a guest user since we'll usually have set a guest user session before
|
||||
// starting the registration process. This isn't perfect since it's possible
|
||||
// the user had a separate guest session they didn't actually mean to replace.
|
||||
const sessionOwner = Lifecycle.getStoredSessionOwner();
|
||||
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
|
||||
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
|
||||
console.log(
|
||||
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
|
||||
);
|
||||
newState.differentLoggedInUserId = sessionOwner;
|
||||
} else {
|
||||
newState.differentLoggedInUserId = null;
|
||||
}
|
||||
|
||||
if (response.access_token) {
|
||||
const cli = await this.props.onLoggedIn({
|
||||
userId: response.user_id,
|
||||
deviceId: response.device_id,
|
||||
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
|
||||
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
|
||||
accessToken: response.access_token,
|
||||
}, this.state.formVals.password);
|
||||
|
||||
this._setupPushers(cli);
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
newState.busy = true;
|
||||
} else {
|
||||
newState.busy = false;
|
||||
newState.completedNoSignin = true;
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
},
|
||||
|
||||
_setupPushers: function(matrixClient) {
|
||||
if (!this.props.brand) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return matrixClient.getPushers().then((resp)=>{
|
||||
const pushers = resp.pushers;
|
||||
for (let i = 0; i < pushers.length; ++i) {
|
||||
if (pushers[i].kind === 'email') {
|
||||
const emailPusher = pushers[i];
|
||||
emailPusher.data = { brand: this.props.brand };
|
||||
matrixClient.setPusher(emailPusher).then(() => {
|
||||
console.log("Set email branding to " + this.props.brand);
|
||||
}, (error) => {
|
||||
console.error("Couldn't set email branding: " + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, (error) => {
|
||||
console.error("Couldn't get pushers: " + error);
|
||||
});
|
||||
},
|
||||
|
||||
onLoginClick: function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onLoginClick();
|
||||
},
|
||||
|
||||
onGoToFormClicked(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._replaceClient();
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
phase: PHASE_REGISTRATION,
|
||||
});
|
||||
},
|
||||
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
this.setState({
|
||||
phase: PHASE_REGISTRATION,
|
||||
});
|
||||
},
|
||||
|
||||
onEditServerDetailsClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
},
|
||||
|
||||
_makeRegisterRequest: function(auth) {
|
||||
// We inhibit login if we're trying to register with an email address: this
|
||||
// avoids a lot of complex race conditions that can occur if we try to log
|
||||
// the user in one one or both of the tabs they might end up with after
|
||||
// clicking the email link.
|
||||
let inhibitLogin = Boolean(this.state.formVals.email);
|
||||
|
||||
// Only send inhibitLogin if we're sending username / pw params
|
||||
// (Since we need to send no params at all to use the ones saved in the
|
||||
// session).
|
||||
if (!this.state.formVals.password) inhibitLogin = null;
|
||||
|
||||
const registerParams = {
|
||||
username: this.state.formVals.username,
|
||||
password: this.state.formVals.password,
|
||||
initial_device_display_name: this.props.defaultDeviceDisplayName,
|
||||
};
|
||||
if (auth) registerParams.auth = auth;
|
||||
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
|
||||
return this.state.matrixClient.registerRequest(registerParams);
|
||||
},
|
||||
|
||||
_getUIAuthInputs: function() {
|
||||
return {
|
||||
emailAddress: this.state.formVals.email,
|
||||
phoneCountry: this.state.formVals.phoneCountry,
|
||||
phoneNumber: this.state.formVals.phoneNumber,
|
||||
};
|
||||
},
|
||||
|
||||
// Links to the login page shown after registration is completed are routed through this
|
||||
// which checks the user hasn't already logged in somewhere else (perhaps we should do
|
||||
// this more generally?)
|
||||
_onLoginClickWithCheck: async function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
|
||||
if (!sessionLoaded) {
|
||||
// ok fine, there's still no session: really go to the login page
|
||||
this.props.onLoginClick();
|
||||
}
|
||||
},
|
||||
|
||||
renderServerComponent() {
|
||||
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we're on a different phase, we only show the server type selector,
|
||||
// which is always shown if we allow custom URLs at all.
|
||||
// (if there's a fatal server error, we need to show the full server
|
||||
// config as the user may need to change servers to resolve the error).
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
|
||||
return <div>
|
||||
<ServerTypeSelector
|
||||
selected={this.state.serverType}
|
||||
onChange={this.onServerTypeChange}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const serverDetailsProps = {};
|
||||
if (PHASES_ENABLED) {
|
||||
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
|
||||
serverDetailsProps.submitText = _t("Next");
|
||||
serverDetailsProps.submitClass = "mx_Login_submit";
|
||||
}
|
||||
|
||||
let serverDetails = null;
|
||||
switch (this.state.serverType) {
|
||||
case ServerType.FREE:
|
||||
break;
|
||||
case ServerType.PREMIUM:
|
||||
serverDetails = <ModularServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
break;
|
||||
case ServerType.ADVANCED:
|
||||
serverDetails = <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
break;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<ServerTypeSelector
|
||||
selected={this.state.serverType}
|
||||
onChange={this.onServerTypeChange}
|
||||
/>
|
||||
{serverDetails}
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderRegisterComponent() {
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
|
||||
|
||||
if (this.state.matrixClient && this.state.doingUIAuth) {
|
||||
return <InteractiveAuth
|
||||
matrixClient={this.state.matrixClient}
|
||||
makeRequest={this._makeRegisterRequest}
|
||||
onAuthFinished={this._onUIAuthFinished}
|
||||
inputs={this._getUIAuthInputs()}
|
||||
requestEmailToken={this._requestEmailToken}
|
||||
sessionId={this.props.sessionId}
|
||||
clientSecret={this.props.clientSecret}
|
||||
emailSid={this.props.idSid}
|
||||
poll={true}
|
||||
/>;
|
||||
} else if (!this.state.matrixClient && !this.state.busy) {
|
||||
return null;
|
||||
} else if (this.state.busy || !this.state.flows) {
|
||||
return <div className="mx_AuthBody_spinner">
|
||||
<Spinner />
|
||||
</div>;
|
||||
} else if (this.state.flows.length) {
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed and we haven't selected the Free server type, wire
|
||||
// up the server details edit link.
|
||||
if (
|
||||
PHASES_ENABLED &&
|
||||
!SdkConfig.get()['disable_custom_urls'] &&
|
||||
this.state.serverType !== ServerType.FREE
|
||||
) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
|
||||
return <RegistrationForm
|
||||
defaultUsername={this.state.formVals.username}
|
||||
defaultEmail={this.state.formVals.email}
|
||||
defaultPhoneCountry={this.state.formVals.phoneCountry}
|
||||
defaultPhoneNumber={this.state.formVals.phoneNumber}
|
||||
defaultPassword={this.state.formVals.password}
|
||||
onRegisterClick={this.onFormSubmit}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick}
|
||||
flows={this.state.flows}
|
||||
serverConfig={this.props.serverConfig}
|
||||
canSubmit={!this.state.serverErrorIsFatal}
|
||||
serverRequiresIdServer={this.state.serverRequiresIdServer}
|
||||
/>;
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let errorText;
|
||||
const err = this.state.errorText;
|
||||
if (err) {
|
||||
errorText = <div className="mx_Login_error">{ err }</div>;
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
"mx_Login_error": true,
|
||||
"mx_Login_serverError": true,
|
||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{ _t('Sign in instead') }
|
||||
</a>;
|
||||
|
||||
// Only show the 'go back' button if you're not looking at the form
|
||||
let goBack;
|
||||
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) {
|
||||
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
|
||||
{ _t('Go back') }
|
||||
</a>;
|
||||
}
|
||||
|
||||
let body;
|
||||
if (this.state.completedNoSignin) {
|
||||
let regDoneText;
|
||||
if (this.state.differentLoggedInUserId) {
|
||||
regDoneText = <div>
|
||||
<p>{_t(
|
||||
"Your new account (%(newAccountId)s) is registered, but you're already " +
|
||||
"logged into a different account (%(loggedInUserId)s).", {
|
||||
newAccountId: this.state.registeredUsername,
|
||||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
},
|
||||
)}</p>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
|
||||
{_t("Continue with previous account")}
|
||||
</AccessibleButton></p>
|
||||
</div>;
|
||||
} else if (this.state.formVals.password) {
|
||||
// We're the client that started the registration
|
||||
regDoneText = <h3>{_t(
|
||||
"<a>Log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
} else {
|
||||
// We're not the original client: the user probably got to us by clicking the
|
||||
// email validation link. We can't offer a 'go straight to your account' link
|
||||
// as we don't have the original creds.
|
||||
regDoneText = <h3>{_t(
|
||||
"You can now close this window or <a>log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
}
|
||||
body = <div>
|
||||
<h2>{_t("Registration Successful")}</h2>
|
||||
{ regDoneText }
|
||||
</div>;
|
||||
} else {
|
||||
body = <div>
|
||||
<h2>{ _t('Create your account') }</h2>
|
||||
{ errorText }
|
||||
{ serverDeadSection }
|
||||
{ this.renderServerComponent() }
|
||||
{ this.renderRegisterComponent() }
|
||||
{ goBack }
|
||||
{ signIn }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
{ body }
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
618
src/components/structures/auth/Registration.tsx
Normal file
618
src/components/structures/auth/Registration.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
/*
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import Login, { ISSOFlow } from "../../../Login";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from '../../views/elements/ServerPicker';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import RegistrationForm from '../../views/auth/RegistrationForm';
|
||||
import AccessibleButton from '../../views/elements/AccessibleButton';
|
||||
import AuthBody from "../../views/auth/AuthBody";
|
||||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
import InteractiveAuth from "../InteractiveAuth";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
defaultDeviceDisplayName: string;
|
||||
email?: string;
|
||||
brand?: string;
|
||||
clientSecret?: string;
|
||||
sessionId?: string;
|
||||
idSid?: string;
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
// for a short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn(params: IMatrixClientCreds, password: string): void;
|
||||
makeRegistrationUrl(params: {
|
||||
/* eslint-disable camelcase */
|
||||
client_secret: string;
|
||||
hs_url: string;
|
||||
is_url?: string;
|
||||
session_id: string;
|
||||
/* eslint-enable camelcase */
|
||||
}): string;
|
||||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick(): void;
|
||||
onServerConfigChange(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
busy: boolean;
|
||||
errorText?: ReactNode;
|
||||
// true if we're waiting for the user to complete
|
||||
// We remember the values entered by the user because
|
||||
// the registration form will be unmounted during the
|
||||
// course of registration, but if there's an error we
|
||||
// want to bring back the registration form with the
|
||||
// values the user entered still in it. We can keep
|
||||
// them in this component's state since this component
|
||||
// persist for the duration of the registration process.
|
||||
formVals: Record<string, string>;
|
||||
// user-interactive auth
|
||||
// If we've been given a session ID, we're resuming
|
||||
// straight back into UI auth
|
||||
doingUIAuth: boolean;
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: boolean;
|
||||
flows: {
|
||||
stages: string[];
|
||||
}[];
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError?: ReactNode;
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient?: MatrixClient;
|
||||
// The user ID we've just registered
|
||||
registeredUsername?: string;
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId?: string;
|
||||
// the SSO flow definition, this is fetched from /login as that's the only
|
||||
// place it is exposed.
|
||||
ssoFlow?: ISSOFlow;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.Registration")
|
||||
export default class Registration extends React.Component<IProps, IState> {
|
||||
loginLogic: Login;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
formVals: {
|
||||
email: this.props.email,
|
||||
},
|
||||
doingUIAuth: Boolean(this.props.sessionId),
|
||||
flows: null,
|
||||
completedNoSignin: false,
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
};
|
||||
|
||||
const { hsUrl, isUrl } = this.props.serverConfig;
|
||||
this.loginLogic = new Login(hsUrl, isUrl, null, {
|
||||
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
this.replaceClient(newProps.serverConfig);
|
||||
}
|
||||
|
||||
private async replaceClient(serverConfig: ValidatedServerConfig) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
serverDeadError: null,
|
||||
serverErrorIsFatal: false,
|
||||
// busy while we do liveness check (we need to avoid trying to render
|
||||
// the UI auth component while we don't have a matrix client)
|
||||
busy: true,
|
||||
});
|
||||
|
||||
// Do a liveliness check on the URLs
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
serverConfig.hsUrl,
|
||||
serverConfig.isUrl,
|
||||
);
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e, "register"),
|
||||
});
|
||||
if (this.state.serverErrorIsFatal) {
|
||||
return; // Server is dead - do not continue.
|
||||
}
|
||||
}
|
||||
|
||||
const { hsUrl, isUrl } = serverConfig;
|
||||
const cli = createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
});
|
||||
|
||||
this.loginLogic.setHomeserverUrl(hsUrl);
|
||||
this.loginLogic.setIdentityServerUrl(isUrl);
|
||||
|
||||
let ssoFlow: ISSOFlow;
|
||||
try {
|
||||
const loginFlows = await this.loginLogic.getFlows();
|
||||
ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow;
|
||||
} catch (e) {
|
||||
console.error("Failed to get login flows to check for SSO support", e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
matrixClient: cli,
|
||||
ssoFlow,
|
||||
busy: false,
|
||||
});
|
||||
const showGenericError = (e) => {
|
||||
this.setState({
|
||||
errorText: _t("Unable to query for supported registration methods."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
};
|
||||
try {
|
||||
// We do the first registration request ourselves to discover whether we need to
|
||||
// do SSO instead. If we've already started the UI Auth process though, we don't
|
||||
// need to.
|
||||
if (!this.state.doingUIAuth) {
|
||||
await this.makeRegisterRequest(null);
|
||||
// This should never succeed since we specified no auth object.
|
||||
console.log("Expecting 401 from register request but got success!");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.httpStatus === 401) {
|
||||
this.setState({
|
||||
flows: e.data.flows,
|
||||
});
|
||||
} else if (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN") {
|
||||
// Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN.
|
||||
// At this point registration is pretty much disabled, but before we do that let's
|
||||
// quickly check to see if the server supports SSO instead. If it does, we'll send
|
||||
// the user off to the login page to figure their account out.
|
||||
if (ssoFlow) {
|
||||
// Redirect to login page - server probably expects SSO only
|
||||
dis.dispatch({ action: 'start_login' });
|
||||
} else {
|
||||
this.setState({
|
||||
serverErrorIsFatal: true, // fatal because user cannot continue on this server
|
||||
errorText: _t("Registration has been disabled on this homeserver."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("Unable to query for supported registration methods.", e);
|
||||
showGenericError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onFormSubmit = async (formVals): Promise<void> => {
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
formVals: formVals,
|
||||
doingUIAuth: true,
|
||||
});
|
||||
};
|
||||
|
||||
private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
|
||||
return this.state.matrixClient.requestRegisterEmailToken(
|
||||
emailAddress,
|
||||
clientSecret,
|
||||
sendAttempt,
|
||||
this.props.makeRegistrationUrl({
|
||||
client_secret: clientSecret,
|
||||
hs_url: this.state.matrixClient.getHomeserverUrl(),
|
||||
is_url: this.state.matrixClient.getIdentityServerUrl(),
|
||||
session_id: sessionId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
private onUIAuthFinished = async (success: boolean, response: any) => {
|
||||
if (!success) {
|
||||
let msg = response.message || response.toString();
|
||||
// can we give a better error message?
|
||||
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact,
|
||||
{
|
||||
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
|
||||
'hs_blocked': _td("This homeserver has been blocked by it's administrator."),
|
||||
'': _td("This homeserver has exceeded one of its resource limits."),
|
||||
},
|
||||
);
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact,
|
||||
{
|
||||
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
|
||||
},
|
||||
);
|
||||
msg = <div>
|
||||
<p>{errorTop}</p>
|
||||
<p>{errorDetail}</p>
|
||||
</div>;
|
||||
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
|
||||
let msisdnAvailable = false;
|
||||
for (const flow of response.available_flows) {
|
||||
msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn');
|
||||
}
|
||||
if (!msisdnAvailable) {
|
||||
msg = _t('This server does not support authentication with a phone number.');
|
||||
}
|
||||
} else if (response.errcode === "M_USER_IN_USE") {
|
||||
msg = _t("That username already exists, please try another.");
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
errorText: msg,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
|
||||
|
||||
const newState = {
|
||||
doingUIAuth: false,
|
||||
registeredUsername: response.user_id,
|
||||
differentLoggedInUserId: null,
|
||||
completedNoSignin: false,
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
busy: true,
|
||||
};
|
||||
|
||||
// The user came in through an email validation link. To avoid overwriting
|
||||
// their session, check to make sure the session isn't someone else, and
|
||||
// isn't a guest user since we'll usually have set a guest user session before
|
||||
// starting the registration process. This isn't perfect since it's possible
|
||||
// the user had a separate guest session they didn't actually mean to replace.
|
||||
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
|
||||
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
|
||||
console.log(
|
||||
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
|
||||
);
|
||||
newState.differentLoggedInUserId = sessionOwner;
|
||||
}
|
||||
|
||||
if (response.access_token) {
|
||||
await this.props.onLoggedIn({
|
||||
userId: response.user_id,
|
||||
deviceId: response.device_id,
|
||||
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
|
||||
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
|
||||
accessToken: response.access_token,
|
||||
}, this.state.formVals.password);
|
||||
|
||||
this.setupPushers();
|
||||
} else {
|
||||
newState.busy = false;
|
||||
newState.completedNoSignin = true;
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
};
|
||||
|
||||
private setupPushers() {
|
||||
if (!this.props.brand) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
return matrixClient.getPushers().then((resp)=>{
|
||||
const pushers = resp.pushers;
|
||||
for (let i = 0; i < pushers.length; ++i) {
|
||||
if (pushers[i].kind === 'email') {
|
||||
const emailPusher = pushers[i];
|
||||
emailPusher.data = { brand: this.props.brand };
|
||||
matrixClient.setPusher(emailPusher).then(() => {
|
||||
console.log("Set email branding to " + this.props.brand);
|
||||
}, (error) => {
|
||||
console.error("Couldn't set email branding: " + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, (error) => {
|
||||
console.error("Couldn't get pushers: " + error);
|
||||
});
|
||||
}
|
||||
|
||||
private onLoginClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onLoginClick();
|
||||
};
|
||||
|
||||
private onGoToFormClicked = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
private makeRegisterRequest = auth => {
|
||||
// We inhibit login if we're trying to register with an email address: this
|
||||
// avoids a lot of complex race conditions that can occur if we try to log
|
||||
// the user in one one or both of the tabs they might end up with after
|
||||
// clicking the email link.
|
||||
let inhibitLogin = Boolean(this.state.formVals.email);
|
||||
|
||||
// Only send inhibitLogin if we're sending username / pw params
|
||||
// (Since we need to send no params at all to use the ones saved in the
|
||||
// session).
|
||||
if (!this.state.formVals.password) inhibitLogin = null;
|
||||
|
||||
const registerParams = {
|
||||
username: this.state.formVals.username,
|
||||
password: this.state.formVals.password,
|
||||
initial_device_display_name: this.props.defaultDeviceDisplayName,
|
||||
auth: undefined,
|
||||
inhibit_login: undefined,
|
||||
};
|
||||
if (auth) registerParams.auth = auth;
|
||||
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
|
||||
return this.state.matrixClient.registerRequest(registerParams);
|
||||
};
|
||||
|
||||
private getUIAuthInputs() {
|
||||
return {
|
||||
emailAddress: this.state.formVals.email,
|
||||
phoneCountry: this.state.formVals.phoneCountry,
|
||||
phoneNumber: this.state.formVals.phoneNumber,
|
||||
};
|
||||
}
|
||||
|
||||
// Links to the login page shown after registration is completed are routed through this
|
||||
// which checks the user hasn't already logged in somewhere else (perhaps we should do
|
||||
// this more generally?)
|
||||
private onLoginClickWithCheck = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true });
|
||||
if (!sessionLoaded) {
|
||||
// ok fine, there's still no session: really go to the login page
|
||||
this.props.onLoginClick();
|
||||
}
|
||||
|
||||
return sessionLoaded;
|
||||
};
|
||||
|
||||
private renderRegisterComponent() {
|
||||
if (this.state.matrixClient && this.state.doingUIAuth) {
|
||||
return <InteractiveAuth
|
||||
matrixClient={this.state.matrixClient}
|
||||
makeRequest={this.makeRegisterRequest}
|
||||
onAuthFinished={this.onUIAuthFinished}
|
||||
inputs={this.getUIAuthInputs()}
|
||||
requestEmailToken={this.requestEmailToken}
|
||||
sessionId={this.props.sessionId}
|
||||
clientSecret={this.props.clientSecret}
|
||||
emailSid={this.props.idSid}
|
||||
poll={true}
|
||||
/>;
|
||||
} else if (!this.state.matrixClient && !this.state.busy) {
|
||||
return null;
|
||||
} else if (this.state.busy || !this.state.flows) {
|
||||
return <div className="mx_AuthBody_spinner">
|
||||
<Spinner />
|
||||
</div>;
|
||||
} else if (this.state.flows.length) {
|
||||
let ssoSection;
|
||||
if (this.state.ssoFlow) {
|
||||
let continueWithSection;
|
||||
const providers = this.state.ssoFlow.identity_providers || [];
|
||||
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
|
||||
if (providers.length > 1) {
|
||||
// i18n: ssoButtons is a placeholder to help translators understand context
|
||||
continueWithSection = <h3 className="mx_AuthBody_centered">
|
||||
{ _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() }
|
||||
</h3>;
|
||||
}
|
||||
|
||||
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
|
||||
ssoSection = <React.Fragment>
|
||||
{ continueWithSection }
|
||||
<SSOButtons
|
||||
matrixClient={this.loginLogic.createTemporaryClient()}
|
||||
flow={this.state.ssoFlow}
|
||||
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
/>
|
||||
<h3 className="mx_AuthBody_centered">
|
||||
{_t(
|
||||
"%(ssoButtons)s Or %(usernamePassword)s",
|
||||
{
|
||||
ssoButtons: "",
|
||||
usernamePassword: "",
|
||||
},
|
||||
).trim()}
|
||||
</h3>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
{ ssoSection }
|
||||
<RegistrationForm
|
||||
defaultUsername={this.state.formVals.username}
|
||||
defaultEmail={this.state.formVals.email}
|
||||
defaultPhoneCountry={this.state.formVals.phoneCountry}
|
||||
defaultPhoneNumber={this.state.formVals.phoneNumber}
|
||||
defaultPassword={this.state.formVals.password}
|
||||
onRegisterClick={this.onFormSubmit}
|
||||
flows={this.state.flows}
|
||||
serverConfig={this.props.serverConfig}
|
||||
canSubmit={!this.state.serverErrorIsFatal}
|
||||
/>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let errorText;
|
||||
const err = this.state.errorText;
|
||||
if (err) {
|
||||
errorText = <div className="mx_Login_error">{ err }</div>;
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
"mx_Login_error": true,
|
||||
"mx_Login_serverError": true,
|
||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = <span className="mx_AuthBody_changeFlow">
|
||||
{_t("Already have an account? <a>Sign in here</a>", {}, {
|
||||
a: sub => <a onClick={this.onLoginClick} href="#">{ sub }</a>,
|
||||
})}
|
||||
</span>;
|
||||
|
||||
// Only show the 'go back' button if you're not looking at the form
|
||||
let goBack;
|
||||
if (this.state.doingUIAuth) {
|
||||
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
|
||||
{ _t('Go back') }
|
||||
</a>;
|
||||
}
|
||||
|
||||
let body;
|
||||
if (this.state.completedNoSignin) {
|
||||
let regDoneText;
|
||||
if (this.state.differentLoggedInUserId) {
|
||||
regDoneText = <div>
|
||||
<p>{_t(
|
||||
"Your new account (%(newAccountId)s) is registered, but you're already " +
|
||||
"logged into a different account (%(loggedInUserId)s).", {
|
||||
newAccountId: this.state.registeredUsername,
|
||||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
},
|
||||
)}</p>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={async event => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_welcome_page" });
|
||||
}
|
||||
}}>
|
||||
{_t("Continue with previous account")}
|
||||
</AccessibleButton></p>
|
||||
</div>;
|
||||
} else if (this.state.formVals.password) {
|
||||
// We're the client that started the registration
|
||||
regDoneText = <h3>{_t(
|
||||
"<a>Log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
} else {
|
||||
// We're not the original client: the user probably got to us by clicking the
|
||||
// email validation link. We can't offer a 'go straight to your account' link
|
||||
// as we don't have the original creds.
|
||||
regDoneText = <h3>{_t(
|
||||
"You can now close this window or <a>log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
}
|
||||
body = <div>
|
||||
<h2>{_t("Registration Successful")}</h2>
|
||||
{ regDoneText }
|
||||
</div>;
|
||||
} else {
|
||||
body = <div>
|
||||
<h2>{ _t('Create account') }</h2>
|
||||
{ errorText }
|
||||
{ serverDeadSection }
|
||||
<ServerPicker
|
||||
title={_t("Host account on")}
|
||||
dialogTitle={_t("Decide where your account is hosted")}
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.state.doingUIAuth ? undefined : this.props.onServerConfigChange}
|
||||
/>
|
||||
{ this.renderRegisterComponent() }
|
||||
{ goBack }
|
||||
{ signIn }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
{ body }
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -15,28 +15,43 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import {
|
||||
SetupEncryptionStore,
|
||||
PHASE_INTRO,
|
||||
PHASE_BUSY,
|
||||
PHASE_DONE,
|
||||
PHASE_CONFIRM_SKIP,
|
||||
PHASE_FINISHED,
|
||||
} from '../../../stores/SetupEncryptionStore';
|
||||
import Modal from '../../../Modal';
|
||||
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
|
||||
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
|
||||
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
|
||||
import AccessibleButton from '../../views/elements/AccessibleButton';
|
||||
import Spinner from '../../views/elements/Spinner';
|
||||
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
|
||||
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||
|
||||
export default class SetupEncryptionBody extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
|
||||
return Boolean(
|
||||
keyInfo.passphrase &&
|
||||
keyInfo.passphrase.salt &&
|
||||
keyInfo.passphrase.iterations,
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
interface IProps {
|
||||
onFinished: (boolean) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
verificationRequest: VerificationRequest;
|
||||
backupInfo: IKeyBackupInfo;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.SetupEncryptionBody")
|
||||
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.on("update", this._onStoreUpdate);
|
||||
store.on("update", this.onStoreUpdate);
|
||||
store.start();
|
||||
this.state = {
|
||||
phase: store.phase,
|
||||
@@ -48,10 +63,10 @@ export default class SetupEncryptionBody extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
_onStoreUpdate = () => {
|
||||
private onStoreUpdate = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
if (store.phase === PHASE_FINISHED) {
|
||||
this.props.onFinished();
|
||||
if (store.phase === Phase.Finished) {
|
||||
this.props.onFinished(true);
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
@@ -61,82 +76,109 @@ export default class SetupEncryptionBody extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount() {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.off("update", this._onStoreUpdate);
|
||||
store.off("update", this.onStoreUpdate);
|
||||
store.stop();
|
||||
}
|
||||
|
||||
_onUsePassphraseClick = async () => {
|
||||
private onUsePassphraseClick = async () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.usePassPhrase();
|
||||
}
|
||||
};
|
||||
|
||||
onSkipClick = () => {
|
||||
private onVerifyClick = () => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
const requestPromise = cli.requestVerification(userId);
|
||||
|
||||
this.props.onFinished(true);
|
||||
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
|
||||
verificationRequestPromise: requestPromise,
|
||||
member: cli.getUser(userId),
|
||||
onFinished: async () => {
|
||||
const request = await requestPromise;
|
||||
request.cancel();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onSkipClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.skip();
|
||||
}
|
||||
};
|
||||
|
||||
onSkipConfirmClick = () => {
|
||||
private onSkipConfirmClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.skipConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
onSkipBackClick = () => {
|
||||
private onSkipBackClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.returnAfterSkip();
|
||||
}
|
||||
};
|
||||
|
||||
onDoneClick = () => {
|
||||
private onDoneClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.done();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
private onEncryptionPanelClose = () => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
phase,
|
||||
} = this.state;
|
||||
|
||||
if (this.state.verificationRequest) {
|
||||
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
|
||||
return <EncryptionPanel
|
||||
layout="dialog"
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
onClose={this.props.onFinished}
|
||||
onClose={this.onEncryptionPanelClose}
|
||||
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
|
||||
isRoomEncrypted={false}
|
||||
/>;
|
||||
} else if (phase === PHASE_INTRO) {
|
||||
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
|
||||
} else if (phase === Phase.Intro) {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
let recoveryKeyPrompt;
|
||||
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
|
||||
recoveryKeyPrompt = _t("Use Security Key or Phrase");
|
||||
} else if (store.keyInfo) {
|
||||
recoveryKeyPrompt = _t("Use Security Key");
|
||||
}
|
||||
|
||||
let useRecoveryKeyButton;
|
||||
if (recoveryKeyPrompt) {
|
||||
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}>
|
||||
{recoveryKeyPrompt}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let verifyButton;
|
||||
if (store.hasDevicesToVerifyAgainst) {
|
||||
verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
|
||||
{ _t("Use another login") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
"Open an existing session & use it to verify this one, " +
|
||||
"granting it access to encrypted messages.",
|
||||
"Verify your identity to access encrypted messages and prove your identity to others.",
|
||||
)}</p>
|
||||
<p className="mx_CompleteSecurity_waiting"><InlineSpinner />{_t("Waiting…")}</p>
|
||||
<p>{_t(
|
||||
"If you can’t access one, <button>use your recovery key or passphrase.</button>",
|
||||
{}, {
|
||||
button: sub => <AccessibleButton element="span"
|
||||
className="mx_linkButton"
|
||||
onClick={this._onUsePassphraseClick}
|
||||
>
|
||||
{sub}
|
||||
</AccessibleButton>,
|
||||
})}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton
|
||||
kind="danger"
|
||||
onClick={this.onSkipClick}
|
||||
>
|
||||
{verifyButton}
|
||||
{useRecoveryKeyButton}
|
||||
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
|
||||
{_t("Skip")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === PHASE_DONE) {
|
||||
} else if (phase === Phase.Done) {
|
||||
let message;
|
||||
if (this.state.backupInfo) {
|
||||
message = <p>{_t(
|
||||
@@ -150,7 +192,7 @@ export default class SetupEncryptionBody extends React.Component {
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified"></div>
|
||||
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified" />
|
||||
{message}
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton
|
||||
@@ -162,12 +204,12 @@ export default class SetupEncryptionBody extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === PHASE_CONFIRM_SKIP) {
|
||||
} else if (phase === Phase.ConfirmSkip) {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
"Without completing security on this session, it won’t have " +
|
||||
"access to encrypted messages.",
|
||||
"Without verifying, you won’t have access to all your messages " +
|
||||
"and may appear as untrusted to others.",
|
||||
)}</p>
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton
|
||||
@@ -186,8 +228,7 @@ export default class SetupEncryptionBody extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === PHASE_BUSY) {
|
||||
const Spinner = sdk.getComponent('views.elements.Spinner');
|
||||
} else if (phase === Phase.Busy || phase === Phase.Loading) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
console.log(`SetupEncryptionBody: Unknown phase ${phase}`);
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -15,16 +15,22 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import Modal from '../../../Modal';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {sendLoginRequest} from "../../../Login";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { ISSOFlow, LoginFlow, sendLoginRequest } from "../../../Login";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import SSOButton from "../../views/elements/SSOButton";
|
||||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog';
|
||||
import Field from '../../views/elements/Field';
|
||||
import AccessibleButton from '../../views/elements/AccessibleButton';
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
import AuthBody from "../../views/auth/AuthBody";
|
||||
|
||||
const LOGIN_VIEW = {
|
||||
LOADING: 1,
|
||||
@@ -40,44 +46,59 @@ const FLOWS_TO_VIEWS = {
|
||||
"m.login.sso": LOGIN_VIEW.SSO,
|
||||
};
|
||||
|
||||
export default class SoftLogout extends React.Component {
|
||||
static propTypes = {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: PropTypes.object, // {homeserver, identityServer, loginToken}
|
||||
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: PropTypes.func,
|
||||
interface IProps {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: {
|
||||
loginToken?: string;
|
||||
};
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loginView: number;
|
||||
keyBackupNeeded: boolean;
|
||||
busy: boolean;
|
||||
password: string;
|
||||
errorText: string;
|
||||
flows: LoginFlow[];
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.SoftLogout")
|
||||
export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loginView: LOGIN_VIEW.LOADING,
|
||||
keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount)
|
||||
|
||||
busy: false,
|
||||
password: "",
|
||||
errorText: "",
|
||||
flows: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
// We've ended up here when we don't need to - navigate to login
|
||||
if (!Lifecycle.isSoftLogout()) {
|
||||
dis.dispatch({action: "start_login"});
|
||||
dis.dispatch({ action: "start_login" });
|
||||
return;
|
||||
}
|
||||
|
||||
this._initLogin();
|
||||
this.initLogin();
|
||||
|
||||
MatrixClientPeg.get().flagAllGroupSessionsForBackup().then(remaining => {
|
||||
this.setState({keyBackupNeeded: remaining > 0});
|
||||
});
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli.isCryptoEnabled()) {
|
||||
cli.countSessionsNeedingBackup().then(remaining => {
|
||||
this.setState({ keyBackupNeeded: remaining > 0 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClearAll = () => {
|
||||
const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog');
|
||||
Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, {
|
||||
onFinished: (wipeData) => {
|
||||
if (!wipeData) return;
|
||||
@@ -88,11 +109,11 @@ export default class SoftLogout extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
async _initLogin() {
|
||||
private async initLogin() {
|
||||
const queryParams = this.props.realQueryParams;
|
||||
const hasAllParams = queryParams && queryParams['homeserver'] && queryParams['loginToken'];
|
||||
const hasAllParams = queryParams && queryParams['loginToken'];
|
||||
if (hasAllParams) {
|
||||
this.setState({loginView: LOGIN_VIEW.LOADING});
|
||||
this.setState({ loginView: LOGIN_VIEW.LOADING });
|
||||
this.trySsoLogin();
|
||||
return;
|
||||
}
|
||||
@@ -100,25 +121,26 @@ export default class SoftLogout extends React.Component {
|
||||
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
|
||||
// care about login flows here, unless it is the single flow we support.
|
||||
const client = MatrixClientPeg.get();
|
||||
const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]);
|
||||
const flows = (await client.loginFlows()).flows;
|
||||
const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]);
|
||||
|
||||
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
|
||||
this.setState({loginView: chosenView});
|
||||
this.setState({ flows, loginView: chosenView });
|
||||
}
|
||||
|
||||
onPasswordChange = (ev) => {
|
||||
this.setState({password: ev.target.value});
|
||||
this.setState({ password: ev.target.value });
|
||||
};
|
||||
|
||||
onForgotPassword = () => {
|
||||
dis.dispatch({action: 'start_password_recovery'});
|
||||
dis.dispatch({ action: 'start_password_recovery' });
|
||||
};
|
||||
|
||||
onPasswordLogin = async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.setState({busy: true});
|
||||
this.setState({ busy: true });
|
||||
|
||||
const hsUrl = MatrixClientPeg.get().getHomeserverUrl();
|
||||
const isUrl = MatrixClientPeg.get().getIdentityServerUrl();
|
||||
@@ -150,15 +172,15 @@ export default class SoftLogout extends React.Component {
|
||||
|
||||
Lifecycle.hydrateSession(credentials).catch((e) => {
|
||||
console.error(e);
|
||||
this.setState({busy: false, errorText: _t("Failed to re-authenticate")});
|
||||
this.setState({ busy: false, errorText: _t("Failed to re-authenticate") });
|
||||
});
|
||||
};
|
||||
|
||||
async trySsoLogin() {
|
||||
this.setState({busy: true});
|
||||
this.setState({ busy: true });
|
||||
|
||||
const hsUrl = this.props.realQueryParams['homeserver'];
|
||||
const isUrl = this.props.realQueryParams['identityServer'] || MatrixClientPeg.get().getIdentityServerUrl();
|
||||
const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
|
||||
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
|
||||
const loginType = "m.login.token";
|
||||
const loginParams = {
|
||||
token: this.props.realQueryParams['loginToken'],
|
||||
@@ -170,7 +192,7 @@ export default class SoftLogout extends React.Component {
|
||||
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
|
||||
this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,13 +200,12 @@ export default class SoftLogout extends React.Component {
|
||||
if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted();
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
|
||||
this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED });
|
||||
});
|
||||
}
|
||||
|
||||
_renderSignInSection() {
|
||||
private renderSignInSection() {
|
||||
if (this.state.loginView === LOGIN_VIEW.LOADING) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
@@ -196,9 +217,6 @@ export default class SoftLogout extends React.Component {
|
||||
}
|
||||
|
||||
if (this.state.loginView === LOGIN_VIEW.PASSWORD) {
|
||||
const Field = sdk.getComponent("elements.Field");
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = <span className='mx_Login_error'>{this.state.errorText}</span>;
|
||||
@@ -239,12 +257,19 @@ export default class SoftLogout extends React.Component {
|
||||
introText = _t("Sign in and regain access to your account.");
|
||||
} // else we already have a message and should use it (key backup warning)
|
||||
|
||||
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
|
||||
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{introText}</p>
|
||||
<SSOButton
|
||||
<SSOButtons
|
||||
matrixClient={MatrixClientPeg.get()}
|
||||
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"} />
|
||||
flow={flow}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -261,10 +286,6 @@ export default class SoftLogout extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
@@ -275,7 +296,7 @@ export default class SoftLogout extends React.Component {
|
||||
|
||||
<h3>{_t("Sign in")}</h3>
|
||||
<div>
|
||||
{this._renderSignInSection()}
|
||||
{this.renderSignInSection()}
|
||||
</div>
|
||||
|
||||
<h3>{_t("Clear personal data")}</h3>
|
||||
124
src/components/views/audio_messages/AudioPlayer.tsx
Normal file
124
src/components/views/audio_messages/AudioPlayer.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { createRef, ReactNode, RefObject } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { formatBytes } from "../../../utils/FormattingUtils";
|
||||
import DurationClock from "./DurationClock";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SeekBar from "./SeekBar";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||
private playPauseRef: RefObject<PlayPauseButton> = createRef();
|
||||
private seekRef: RefObject<SeekBar> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.prepare();
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// stopPropagation() prevents the FocusComposer catch-all from triggering,
|
||||
// but we need to do it on key down instead of press (even though the user
|
||||
// interaction is typically on press).
|
||||
if (ev.key === Key.SPACE) {
|
||||
ev.stopPropagation();
|
||||
this.playPauseRef.current?.toggleState();
|
||||
} else if (ev.key === Key.ARROW_LEFT) {
|
||||
ev.stopPropagation();
|
||||
this.seekRef.current?.left();
|
||||
} else if (ev.key === Key.ARROW_RIGHT) {
|
||||
ev.stopPropagation();
|
||||
this.seekRef.current?.right();
|
||||
}
|
||||
};
|
||||
|
||||
protected renderFileSize(): string {
|
||||
const bytes = this.props.playback.sizeBytes;
|
||||
if (!bytes) return null;
|
||||
|
||||
// Not translated here - we're just presenting the data which should already
|
||||
// be translated if needed.
|
||||
return `(${formatBytes(bytes)})`;
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
|
||||
// events for accessibility
|
||||
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<div className='mx_AudioPlayer_primaryContainer'>
|
||||
<PlayPauseButton
|
||||
playback={this.props.playback}
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
tabIndex={-1} // prevent tabbing into the button
|
||||
ref={this.playPauseRef}
|
||||
/>
|
||||
<div className='mx_AudioPlayer_mediaInfo'>
|
||||
<span className='mx_AudioPlayer_mediaName'>
|
||||
{this.props.mediaName || _t("Unnamed audio")}
|
||||
</span>
|
||||
<div className='mx_AudioPlayer_byline'>
|
||||
<DurationClock playback={this.props.playback} />
|
||||
{/* easiest way to introduce a gap between the components */}
|
||||
{ this.renderFileSize() }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx_AudioPlayer_seek'>
|
||||
<SeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={-1} // prevent tabbing into the bar
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
ref={this.seekRef}
|
||||
/>
|
||||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
48
src/components/views/audio_messages/Clock.tsx
Normal file
48
src/components/views/audio_messages/Clock.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export interface IProps {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Simply converts seconds into minutes and seconds. Note that hours will not be
|
||||
* displayed, making it possible to see "82:29".
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.Clock")
|
||||
export default class Clock extends React.Component<IProps, IState> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
|
||||
const currentFloor = Math.floor(this.props.seconds);
|
||||
const nextFloor = Math.floor(nextProps.seconds);
|
||||
return currentFloor !== nextFloor;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
|
||||
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
|
||||
return <span className='mx_Clock'>{minutes}:{seconds}</span>;
|
||||
}
|
||||
}
|
||||
55
src/components/views/audio_messages/DurationClock.tsx
Normal file
55
src/components/views/audio_messages/DurationClock.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A clock which shows a clip's maximum duration.
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.DurationClock")
|
||||
export default class DurationClock extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
// we track the duration on state because we won't really know what the clip duration
|
||||
// is until the first time update, and as a PureComponent we are trying to dedupe state
|
||||
// updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
|
||||
// member property to track "did we get a duration".
|
||||
durationSeconds: this.props.playback.clockInfo.durationSeconds,
|
||||
};
|
||||
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
|
||||
}
|
||||
|
||||
private onTimeUpdate = (time: number[]) => {
|
||||
this.setState({ durationSeconds: time[1] });
|
||||
};
|
||||
|
||||
public render() {
|
||||
return <Clock seconds={this.state.durationSeconds} />;
|
||||
}
|
||||
}
|
||||
65
src/components/views/audio_messages/LiveRecordingClock.tsx
Normal file
65
src/components/views/audio_messages/LiveRecordingClock.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
interface IProps {
|
||||
recorder: VoiceRecording;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A clock for a live recording.
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.LiveRecordingClock")
|
||||
export default class LiveRecordingClock extends React.PureComponent<IProps, IState> {
|
||||
private seconds = 0;
|
||||
private scheduledUpdate = new MarkedExecution(
|
||||
() => this.updateClock(),
|
||||
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
|
||||
);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
seconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||
this.seconds = update.timeSeconds;
|
||||
this.scheduledUpdate.mark();
|
||||
});
|
||||
}
|
||||
|
||||
private updateClock() {
|
||||
this.setState({
|
||||
seconds: this.seconds,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <Clock seconds={this.state.seconds} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arrayFastResample } from "../../../utils/arrays";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
import Waveform from "./Waveform";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
interface IProps {
|
||||
recorder: VoiceRecording;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
waveform: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A waveform which shows the waveform of a live recording
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.LiveRecordingWaveform")
|
||||
export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
progress: 1,
|
||||
};
|
||||
|
||||
private waveform: number[] = [];
|
||||
private scheduledUpdate = new MarkedExecution(
|
||||
() => this.updateWaveform(),
|
||||
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
|
||||
);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
waveform: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
// The incoming data is between zero and one, but typically even screaming into a
|
||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
||||
// user.
|
||||
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
|
||||
this.scheduledUpdate.mark();
|
||||
});
|
||||
}
|
||||
|
||||
private updateWaveform() {
|
||||
this.setState({ waveform: this.waveform });
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <Waveform relHeights={this.state.waveform} />;
|
||||
}
|
||||
}
|
||||
69
src/components/views/audio_messages/PlayPauseButton.tsx
Normal file
69
src/components/views/audio_messages/PlayPauseButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import classNames from "classnames";
|
||||
|
||||
// omitted props are handled by render function
|
||||
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "title" | "onClick" | "disabled"> {
|
||||
// Playback instance to manipulate. Cannot change during the component lifecycle.
|
||||
playback: Playback;
|
||||
|
||||
// The playback phase to render. Able to change during the component lifecycle.
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a play/pause button (activating the play/pause function of the recorder)
|
||||
* to be displayed in reference to a recording.
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.PlayPauseButton")
|
||||
export default class PlayPauseButton extends React.PureComponent<IProps> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
private onClick = () => {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.toggleState();
|
||||
};
|
||||
|
||||
public async toggleState() {
|
||||
await this.props.playback.toggle();
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
const { playback, playbackPhase, ...restProps } = this.props;
|
||||
const isPlaying = playback.isPlaying;
|
||||
const isDisabled = playbackPhase === PlaybackState.Decoding;
|
||||
const classes = classNames('mx_PlayPauseButton', {
|
||||
'mx_PlayPauseButton_play': !isPlaying,
|
||||
'mx_PlayPauseButton_pause': isPlaying,
|
||||
'mx_PlayPauseButton_disabled': isDisabled,
|
||||
});
|
||||
return <AccessibleTooltipButton
|
||||
className={classes}
|
||||
title={isPlaying ? _t("Pause") : _t("Play")}
|
||||
onClick={this.onClick}
|
||||
disabled={isDisabled}
|
||||
{...restProps}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
80
src/components/views/audio_messages/PlaybackClock.tsx
Normal file
80
src/components/views/audio_messages/PlaybackClock.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
|
||||
// The default number of seconds to show when the playback has completed or
|
||||
// has not started. Not used during playback, even when paused. Defaults to
|
||||
// clip duration length.
|
||||
defaultDisplaySeconds?: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
seconds: number;
|
||||
durationSeconds: number;
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
/**
|
||||
* A clock for a playback of a recording.
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.PlaybackClock")
|
||||
export default class PlaybackClock extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
seconds: this.props.playback.clockInfo.timeSeconds,
|
||||
// we track the duration on state because we won't really know what the clip duration
|
||||
// is until the first time update, and as a PureComponent we are trying to dedupe state
|
||||
// updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
|
||||
// member property to track "did we get a duration".
|
||||
durationSeconds: this.props.playback.clockInfo.durationSeconds,
|
||||
playbackPhase: PlaybackState.Stopped, // assume not started, so full clock
|
||||
};
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
// Convert Decoding -> Stopped because we don't care about the distinction here
|
||||
if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped;
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
private onTimeUpdate = (time: number[]) => {
|
||||
this.setState({ seconds: time[0], durationSeconds: time[1] });
|
||||
};
|
||||
|
||||
public render() {
|
||||
let seconds = this.state.seconds;
|
||||
if (this.state.playbackPhase === PlaybackState.Stopped) {
|
||||
if (Number.isFinite(this.props.defaultDisplaySeconds)) {
|
||||
seconds = this.props.defaultDisplaySeconds;
|
||||
} else {
|
||||
seconds = this.state.durationSeconds;
|
||||
}
|
||||
}
|
||||
return <Clock seconds={seconds} />;
|
||||
}
|
||||
}
|
||||
68
src/components/views/audio_messages/PlaybackWaveform.tsx
Normal file
68
src/components/views/audio_messages/PlaybackWaveform.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
heights: number[];
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A waveform which shows the waveform of a previously recorded recording
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.PlaybackWaveform")
|
||||
export default class PlaybackWaveform extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
heights: this.toHeights(this.props.playback.waveform),
|
||||
progress: 0, // default no progress
|
||||
};
|
||||
|
||||
this.props.playback.waveformData.onUpdate(this.onWaveformUpdate);
|
||||
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
|
||||
}
|
||||
|
||||
private toHeights(waveform: number[]) {
|
||||
const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
|
||||
return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed);
|
||||
}
|
||||
|
||||
private onWaveformUpdate = (waveform: number[]) => {
|
||||
this.setState({ heights: this.toHeights(waveform) });
|
||||
};
|
||||
|
||||
private onTimeUpdate = (time: number[]) => {
|
||||
// Track percentages to a general precision to avoid over-waking the component.
|
||||
const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3));
|
||||
this.setState({ progress });
|
||||
};
|
||||
|
||||
public render() {
|
||||
return <Waveform relHeights={this.state.heights} progress={this.state.progress} />;
|
||||
}
|
||||
}
|
||||
74
src/components/views/audio_messages/RecordingPlayback.tsx
Normal file
74
src/components/views/audio_messages/RecordingPlayback.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { ReactNode } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import PlaybackWaveform from "./PlaybackWaveform";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
tileShape?: TileShape;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
||||
export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.prepare();
|
||||
}
|
||||
|
||||
private get isWaveformable(): boolean {
|
||||
return this.props.tileShape !== TileShape.Notif
|
||||
&& this.props.tileShape !== TileShape.FileGrid
|
||||
&& this.props.tileShape !== TileShape.Pinned;
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
|
||||
return <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
|
||||
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
|
||||
<PlaybackClock playback={this.props.playback} />
|
||||
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
112
src/components/views/audio_messages/SeekBar.tsx
Normal file
112
src/components/views/audio_messages/SeekBar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
// Tab index for the underlying component. Useful if the seek bar is in a managed state.
|
||||
// Defaults to zero.
|
||||
tabIndex?: number;
|
||||
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface ISeekCSS extends CSSProperties {
|
||||
'--fillTo': number;
|
||||
}
|
||||
|
||||
const ARROW_SKIP_SECONDS = 5; // arbitrary
|
||||
|
||||
@replaceableComponent("views.audio_messages.SeekBar")
|
||||
export default class SeekBar extends React.PureComponent<IProps, IState> {
|
||||
// We use an animation frame request to avoid overly spamming prop updates, even if we aren't
|
||||
// really using anything demanding on the CSS front.
|
||||
|
||||
private animationFrameFn = new MarkedExecution(
|
||||
() => this.doUpdate(),
|
||||
() => requestAnimationFrame(() => this.animationFrameFn.trigger()));
|
||||
|
||||
public static defaultProps = {
|
||||
tabIndex: 0,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
percentage: 0,
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark());
|
||||
}
|
||||
|
||||
private doUpdate() {
|
||||
this.setState({
|
||||
percentage: percentageOf(
|
||||
this.props.playback.clockInfo.timeSeconds,
|
||||
0,
|
||||
this.props.playback.clockInfo.durationSeconds),
|
||||
});
|
||||
}
|
||||
|
||||
public left() {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS);
|
||||
}
|
||||
|
||||
public right() {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS);
|
||||
}
|
||||
|
||||
private onChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
// Thankfully, onChange is only called when the user changes the value, not when we
|
||||
// change the value on the component. We can use this as a reliable "skip to X" function.
|
||||
//
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds);
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
// We use a range input to avoid having to re-invent accessibility handling on
|
||||
// a custom set of divs.
|
||||
return <input
|
||||
type="range"
|
||||
className='mx_SeekBar'
|
||||
tabIndex={this.props.tabIndex}
|
||||
onChange={this.onChange}
|
||||
min={0}
|
||||
max={1}
|
||||
value={this.state.percentage}
|
||||
step={0.001}
|
||||
style={{ '--fillTo': this.state.percentage } as ISeekCSS}
|
||||
disabled={this.props.playbackPhase === PlaybackState.Decoding}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
63
src/components/views/audio_messages/Waveform.tsx
Normal file
63
src/components/views/audio_messages/Waveform.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import classNames from "classnames";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
interface WaveformCSSProperties extends CSSProperties {
|
||||
'--barHeight': number;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
relHeights: number[]; // relative heights (0-1)
|
||||
progress: number; // percent complete, 0-1, default 100%
|
||||
}
|
||||
|
||||
interface IState {
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple waveform component. This renders bars (centered vertically) for each
|
||||
* height provided in the component properties. Updating the properties will update
|
||||
* the rendered waveform.
|
||||
*
|
||||
* For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be
|
||||
* "filled", as a demonstration of the progress property.
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.Waveform")
|
||||
export default class Waveform extends React.PureComponent<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
progress: 1,
|
||||
};
|
||||
|
||||
public render() {
|
||||
return <div className='mx_Waveform'>
|
||||
{this.props.relHeights.map((h, i) => {
|
||||
const progress = this.props.progress;
|
||||
const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0;
|
||||
const classes = classNames({
|
||||
'mx_Waveform_bar': true,
|
||||
'mx_Waveform_bar_100pct': isCompleteBar,
|
||||
});
|
||||
return <span key={i} style={{
|
||||
"--barHeight": h,
|
||||
} as WaveformCSSProperties} className={classes} />;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.auth.AuthBody")
|
||||
export default class AuthBody extends React.PureComponent {
|
||||
render() {
|
||||
return <div className="mx_AuthBody">
|
||||
|
||||
@@ -18,16 +18,15 @@ limitations under the License.
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'AuthFooter',
|
||||
|
||||
render: function() {
|
||||
@replaceableComponent("views.auth.AuthFooter")
|
||||
export default class AuthFooter extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="mx_AuthFooter">
|
||||
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +16,25 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'AuthHeader',
|
||||
@replaceableComponent("views.auth.AuthHeader")
|
||||
export default class AuthHeader extends React.Component {
|
||||
static propTypes = {
|
||||
disableLanguageSelector: PropTypes.bool,
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo');
|
||||
const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector');
|
||||
|
||||
return (
|
||||
<div className="mx_AuthHeader">
|
||||
<AuthHeaderLogo />
|
||||
<LanguageSelector />
|
||||
<LanguageSelector disabled={this.props.disableLanguageSelector} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.auth.AuthHeaderLogo")
|
||||
export default class AuthHeaderLogo extends React.PureComponent {
|
||||
render() {
|
||||
return <div className="mx_AuthHeaderLogo">
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.auth.AuthPage")
|
||||
export default class AuthPage extends React.PureComponent {
|
||||
|
||||
@@ -14,46 +14,45 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
const DIV_ID = 'mx_recaptcha';
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a captcha form.
|
||||
*/
|
||||
export default createReactClass({
|
||||
displayName: 'CaptchaForm',
|
||||
|
||||
propTypes: {
|
||||
@replaceableComponent("views.auth.CaptchaForm")
|
||||
export default class CaptchaForm extends React.Component {
|
||||
static propTypes = {
|
||||
sitePublicKey: PropTypes.string,
|
||||
|
||||
// called with the captcha response
|
||||
onCaptchaResponse: PropTypes.func,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onCaptchaResponse: () => {},
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
onCaptchaResponse: () => {},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
errorText: null,
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._captchaWidgetId = null;
|
||||
|
||||
this._recaptchaContainer = createRef();
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
|
||||
// so we do this instead.
|
||||
if (global.grecaptcha) {
|
||||
@@ -68,13 +67,13 @@ export default createReactClass({
|
||||
);
|
||||
this._recaptchaContainer.current.appendChild(scriptTag);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this._resetRecaptcha();
|
||||
},
|
||||
}
|
||||
|
||||
_renderRecaptcha: function(divId) {
|
||||
_renderRecaptcha(divId) {
|
||||
if (!global.grecaptcha) {
|
||||
console.error("grecaptcha not loaded!");
|
||||
throw new Error("Recaptcha did not load successfully");
|
||||
@@ -93,26 +92,32 @@ export default createReactClass({
|
||||
sitekey: publicKey,
|
||||
callback: this.props.onCaptchaResponse,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_resetRecaptcha: function() {
|
||||
_resetRecaptcha() {
|
||||
if (this._captchaWidgetId !== null) {
|
||||
global.grecaptcha.reset(this._captchaWidgetId);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_onCaptchaLoaded: function() {
|
||||
_onCaptchaLoaded() {
|
||||
console.log("Loaded recaptcha script.");
|
||||
try {
|
||||
this._renderRecaptcha(DIV_ID);
|
||||
// clear error if re-rendered
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded");
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
errorText: e.toString(),
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_error", { error: e.toString() });
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
@@ -131,5 +136,5 @@ export default createReactClass({
|
||||
{ error }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.auth.CompleteSecurityBody")
|
||||
export default class CompleteSecurityBody extends React.PureComponent {
|
||||
render() {
|
||||
return <div className="mx_CompleteSecurityBody">
|
||||
|
||||
@@ -19,9 +19,10 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
|
||||
import { COUNTRIES } from '../../../phonenumber';
|
||||
import { COUNTRIES, getEmojiFlag } from '../../../phonenumber';
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
const COUNTRIES_BY_ISO2 = {};
|
||||
for (const c of COUNTRIES) {
|
||||
@@ -40,6 +41,7 @@ function countryMatchesSearchQuery(query, country) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.auth.CountryDropdown")
|
||||
export default class CountryDropdown extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -80,7 +82,7 @@ export default class CountryDropdown extends React.Component {
|
||||
}
|
||||
|
||||
_flagImgForIso2(iso2) {
|
||||
return <img src={require(`../../../../res/img/flags/${iso2}.png`)} />;
|
||||
return <div className="mx_Dropdown_option_emoji">{ getEmojiFlag(iso2) }</div>;
|
||||
}
|
||||
|
||||
_getShortOption(iso2) {
|
||||
@@ -123,7 +125,7 @@ export default class CountryDropdown extends React.Component {
|
||||
const options = displayedCountries.map((country) => {
|
||||
return <div className="mx_CountryDropdown_option" key={country.iso2}>
|
||||
{ this._flagImgForIso2(country.iso2) }
|
||||
{ country.name } (+{ country.prefix })
|
||||
{ _t(country.name) } (+{ country.prefix })
|
||||
</div>;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,47 +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 { _t } from '../../../languageHandler';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'CustomServerDialog',
|
||||
|
||||
render: function() {
|
||||
return (
|
||||
<div className="mx_ErrorDialog">
|
||||
<div className="mx_Dialog_title">
|
||||
{ _t("Custom Server Options") }
|
||||
</div>
|
||||
<div className="mx_Dialog_content">
|
||||
<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 " +
|
||||
"different homeserver.",
|
||||
)}</p>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.props.onFinished} autoFocus={true}>
|
||||
{ _t("Dismiss") }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -16,16 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import url from 'url';
|
||||
import classnames from 'classnames';
|
||||
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { LocalisedPolicy, Policies } from '../../../Terms';
|
||||
import Field from '../elements/Field';
|
||||
import CaptchaForm from "./CaptchaForm";
|
||||
|
||||
/* This file contains a collection of components which are used by the
|
||||
* InteractiveAuth to prompt the user to enter the information needed
|
||||
@@ -40,7 +41,7 @@ import AccessibleButton from "../elements/AccessibleButton";
|
||||
* one HS whilst beign a guest on another).
|
||||
* loginType: the login type of the auth stage being attempted
|
||||
* authSessionId: session id from the server
|
||||
* clientSecret: The client secret in use for ID server auth sessions
|
||||
* clientSecret: The client secret in use for identity server auth sessions
|
||||
* stageParams: params from the server for the stage being attempted
|
||||
* errorText: error message from a previous attempt to authenticate
|
||||
* submitAuthDict: a function which will be called with the new auth dict
|
||||
@@ -53,8 +54,8 @@ import AccessibleButton from "../elements/AccessibleButton";
|
||||
* Defined keys for stages are:
|
||||
* m.login.email.identity:
|
||||
* * emailSid: string representing the sid of the active
|
||||
* verification session from the ID server, or
|
||||
* null if no session is active.
|
||||
* verification session from the identity server,
|
||||
* or null if no session is active.
|
||||
* fail: a function which should be called with an error object if an
|
||||
* error occurred during the auth stage. This will cause the auth
|
||||
* session to be failed and the process to go back to the start.
|
||||
@@ -73,43 +74,74 @@ import AccessibleButton from "../elements/AccessibleButton";
|
||||
* focus: set the input focus appropriately in the form.
|
||||
*/
|
||||
|
||||
enum AuthType {
|
||||
Password = "m.login.password",
|
||||
Recaptcha = "m.login.recaptcha",
|
||||
Terms = "m.login.terms",
|
||||
Email = "m.login.email.identity",
|
||||
Msisdn = "m.login.msisdn",
|
||||
Sso = "m.login.sso",
|
||||
SsoUnstable = "org.matrix.login.sso",
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IAuthDict {
|
||||
type?: AuthType;
|
||||
// TODO: Remove `user` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
user?: string;
|
||||
identifier?: any;
|
||||
password?: string;
|
||||
response?: string;
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
threepid_creds?: any;
|
||||
threepidCreds?: any;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export const DEFAULT_PHASE = 0;
|
||||
|
||||
export const PasswordAuthEntry = createReactClass({
|
||||
displayName: 'PasswordAuthEntry',
|
||||
interface IAuthEntryProps {
|
||||
matrixClient: MatrixClient;
|
||||
loginType: string;
|
||||
authSessionId: string;
|
||||
errorText?: string;
|
||||
// Is the auth logic currently waiting for something to happen?
|
||||
busy?: boolean;
|
||||
onPhaseChange: (phase: number) => void;
|
||||
submitAuthDict: (auth: IAuthDict) => void;
|
||||
}
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.password",
|
||||
},
|
||||
interface IPasswordAuthEntryState {
|
||||
password: string;
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
// is the auth logic currently waiting for something to
|
||||
// happen?
|
||||
busy: PropTypes.bool,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
@replaceableComponent("views.auth.PasswordAuthEntry")
|
||||
export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswordAuthEntryState> {
|
||||
static LOGIN_TYPE = AuthType.Password;
|
||||
|
||||
componentDidMount: function() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
this.state = {
|
||||
password: "",
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
_onSubmit: function(e) {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
}
|
||||
|
||||
private onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (this.props.busy) return;
|
||||
|
||||
this.props.submitAuthDict({
|
||||
type: PasswordAuthEntry.LOGIN_TYPE,
|
||||
type: AuthType.Password,
|
||||
// TODO: Remove `user` once servers support proper UIA
|
||||
// See https://github.com/vector-im/riot-web/issues/10312
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
user: this.props.matrixClient.credentials.userId,
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
@@ -117,24 +149,23 @@ export const PasswordAuthEntry = createReactClass({
|
||||
},
|
||||
password: this.state.password,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onPasswordFieldChange: function(ev) {
|
||||
private onPasswordFieldChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
// enable the submit button iff the password is non-empty
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
const passwordBoxClass = classnames({
|
||||
render() {
|
||||
const passwordBoxClass = classNames({
|
||||
"error": this.props.errorText,
|
||||
});
|
||||
|
||||
let submitButtonOrSpinner;
|
||||
if (this.props.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
submitButtonOrSpinner = <Loader />;
|
||||
submitButtonOrSpinner = <Spinner />;
|
||||
} else {
|
||||
submitButtonOrSpinner = (
|
||||
<input type="submit"
|
||||
@@ -154,12 +185,10 @@ export const PasswordAuthEntry = createReactClass({
|
||||
);
|
||||
}
|
||||
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("Confirm your identity by entering your account password below.") }</p>
|
||||
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
|
||||
<form onSubmit={this.onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
|
||||
<Field
|
||||
className={passwordBoxClass}
|
||||
type="password"
|
||||
@@ -167,53 +196,49 @@ export const PasswordAuthEntry = createReactClass({
|
||||
label={_t('Password')}
|
||||
autoFocus={true}
|
||||
value={this.state.password}
|
||||
onChange={this._onPasswordFieldChange}
|
||||
onChange={this.onPasswordFieldChange}
|
||||
/>
|
||||
<div className="mx_button_row">
|
||||
{ submitButtonOrSpinner }
|
||||
</div>
|
||||
</form>
|
||||
{ errorSection }
|
||||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const RecaptchaAuthEntry = createReactClass({
|
||||
displayName: 'RecaptchaAuthEntry',
|
||||
/* eslint-disable camelcase */
|
||||
interface IRecaptchaAuthEntryProps extends IAuthEntryProps {
|
||||
stageParams?: {
|
||||
public_key?: string;
|
||||
};
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.recaptcha",
|
||||
},
|
||||
@replaceableComponent("views.auth.RecaptchaAuthEntry")
|
||||
export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps> {
|
||||
static LOGIN_TYPE = AuthType.Recaptcha;
|
||||
|
||||
propTypes: {
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
stageParams: PropTypes.object.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
busy: PropTypes.bool,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
}
|
||||
|
||||
_onCaptchaResponse: function(response) {
|
||||
private onCaptchaResponse = (response: string) => {
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
|
||||
this.props.submitAuthDict({
|
||||
type: RecaptchaAuthEntry.LOGIN_TYPE,
|
||||
type: AuthType.Recaptcha,
|
||||
response: response,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (this.props.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
let errorText = this.props.errorText;
|
||||
|
||||
const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm");
|
||||
let sitePublicKey;
|
||||
if (!this.props.stageParams || !this.props.stageParams.public_key) {
|
||||
errorText = _t(
|
||||
@@ -236,36 +261,40 @@ export const RecaptchaAuthEntry = createReactClass({
|
||||
return (
|
||||
<div>
|
||||
<CaptchaForm sitePublicKey={sitePublicKey}
|
||||
onCaptchaResponse={this._onCaptchaResponse}
|
||||
onCaptchaResponse={this.onCaptchaResponse}
|
||||
/>
|
||||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const TermsAuthEntry = createReactClass({
|
||||
displayName: 'TermsAuthEntry',
|
||||
interface ITermsAuthEntryProps extends IAuthEntryProps {
|
||||
stageParams?: {
|
||||
policies?: Policies;
|
||||
};
|
||||
showContinue: boolean;
|
||||
}
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.terms",
|
||||
},
|
||||
interface LocalisedPolicyWithId extends LocalisedPolicy {
|
||||
id: string;
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
stageParams: PropTypes.object.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
busy: PropTypes.bool,
|
||||
showContinue: PropTypes.bool,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
interface ITermsAuthEntryState {
|
||||
policies: LocalisedPolicyWithId[];
|
||||
toggledPolicies: {
|
||||
[policy: string]: boolean;
|
||||
};
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
componentDidMount: function() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
@replaceableComponent("views.auth.TermsAuthEntry")
|
||||
export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITermsAuthEntryState> {
|
||||
static LOGIN_TYPE = AuthType.Terms;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
componentWillMount: function() {
|
||||
// example stageParams:
|
||||
//
|
||||
// {
|
||||
@@ -306,21 +335,30 @@ export const TermsAuthEntry = createReactClass({
|
||||
|
||||
initToggles[policyId] = false;
|
||||
|
||||
langPolicy.id = policyId;
|
||||
pickedPolicies.push(langPolicy);
|
||||
pickedPolicies.push({
|
||||
id: policyId,
|
||||
name: langPolicy.name,
|
||||
url: langPolicy.url,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
"toggledPolicies": initToggles,
|
||||
"policies": pickedPolicies,
|
||||
});
|
||||
},
|
||||
this.state = {
|
||||
toggledPolicies: initToggles,
|
||||
policies: pickedPolicies,
|
||||
};
|
||||
|
||||
tryContinue: function() {
|
||||
this._trySubmit();
|
||||
},
|
||||
CountlyAnalytics.instance.track("onboarding_terms_begin");
|
||||
}
|
||||
|
||||
_togglePolicy: function(policyId) {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
}
|
||||
|
||||
public tryContinue = () => {
|
||||
this.trySubmit();
|
||||
};
|
||||
|
||||
private togglePolicy(policyId: string) {
|
||||
const newToggles = {};
|
||||
for (const policy of this.state.policies) {
|
||||
let checked = this.state.toggledPolicies[policy.id];
|
||||
@@ -328,24 +366,27 @@ export const TermsAuthEntry = createReactClass({
|
||||
|
||||
newToggles[policy.id] = checked;
|
||||
}
|
||||
this.setState({"toggledPolicies": newToggles});
|
||||
},
|
||||
this.setState({ "toggledPolicies": newToggles });
|
||||
}
|
||||
|
||||
_trySubmit: function() {
|
||||
private trySubmit = () => {
|
||||
let allChecked = true;
|
||||
for (const policy of this.state.policies) {
|
||||
const checked = this.state.toggledPolicies[policy.id];
|
||||
allChecked = allChecked && checked;
|
||||
}
|
||||
|
||||
if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
|
||||
else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
|
||||
},
|
||||
if (allChecked) {
|
||||
this.props.submitAuthDict({ type: AuthType.Terms });
|
||||
CountlyAnalytics.instance.track("onboarding_terms_complete");
|
||||
} else {
|
||||
this.setState({ errorText: _t("Please review and accept all of the homeserver's policies") });
|
||||
}
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (this.props.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const checkboxes = [];
|
||||
@@ -355,8 +396,9 @@ export const TermsAuthEntry = createReactClass({
|
||||
allChecked = allChecked && checked;
|
||||
|
||||
checkboxes.push(
|
||||
// XXX: replace with StyledCheckbox
|
||||
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
|
||||
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} />
|
||||
<input type="checkbox" onChange={() => this.togglePolicy(policy.id)} checked={checked} />
|
||||
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
|
||||
</label>,
|
||||
);
|
||||
@@ -375,7 +417,7 @@ export const TermsAuthEntry = createReactClass({
|
||||
if (this.props.showContinue !== false) {
|
||||
// XXX: button classes
|
||||
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
|
||||
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
|
||||
onClick={this.trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -386,121 +428,121 @@ export const TermsAuthEntry = createReactClass({
|
||||
{ submitButton }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const EmailIdentityAuthEntry = createReactClass({
|
||||
displayName: 'EmailIdentityAuthEntry',
|
||||
interface IEmailIdentityAuthEntryProps extends IAuthEntryProps {
|
||||
inputs?: {
|
||||
emailAddress?: string;
|
||||
};
|
||||
stageState?: {
|
||||
emailSid: string;
|
||||
};
|
||||
}
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.email.identity",
|
||||
},
|
||||
@replaceableComponent("views.auth.EmailIdentityAuthEntry")
|
||||
export class EmailIdentityAuthEntry extends React.Component<IEmailIdentityAuthEntryProps> {
|
||||
static LOGIN_TYPE = AuthType.Email;
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
authSessionId: PropTypes.string.isRequired,
|
||||
clientSecret: PropTypes.string.isRequired,
|
||||
inputs: PropTypes.object.isRequired,
|
||||
stageState: PropTypes.object.isRequired,
|
||||
fail: PropTypes.func.isRequired,
|
||||
setEmailSid: PropTypes.func.isRequired,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
}
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
requestingToken: false,
|
||||
};
|
||||
},
|
||||
|
||||
render: function() {
|
||||
if (this.state.requestingToken) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
render() {
|
||||
// This component is now only displayed once the token has been requested,
|
||||
// so we know the email has been sent. It can also get loaded after the user
|
||||
// has clicked the validation link if the server takes a while to propagate
|
||||
// the validation internally. If we're in the session spawned from clicking
|
||||
// the validation link, we won't know the email address, so if we don't have it,
|
||||
// assume that the link has been clicked and the server will realise when we poll.
|
||||
if (this.props.inputs.emailAddress === undefined) {
|
||||
return <Spinner />;
|
||||
} else if (this.props.stageState?.emailSid) {
|
||||
// we only have a session ID if the user has clicked the link in their email,
|
||||
// so show a loading state instead of "an email has been sent to..." because
|
||||
// that's confusing when you've already read that email.
|
||||
return <Spinner />;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("An email has been sent to %(emailAddress)s",
|
||||
{ emailAddress: (sub) => <i>{ this.props.inputs.emailAddress }</i> },
|
||||
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
|
||||
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
|
||||
{ emailAddress: <b>{ this.props.inputs.emailAddress }</b> },
|
||||
) }
|
||||
</p>
|
||||
<p>{ _t("Please check your email to continue registration.") }</p>
|
||||
<p>{ _t("Open the link in the email to continue registration.") }</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const MsisdnAuthEntry = createReactClass({
|
||||
displayName: 'MsisdnAuthEntry',
|
||||
interface IMsisdnAuthEntryProps extends IAuthEntryProps {
|
||||
inputs: {
|
||||
phoneCountry: string;
|
||||
phoneNumber: string;
|
||||
};
|
||||
clientSecret: string;
|
||||
fail: (error: Error) => void;
|
||||
}
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.msisdn",
|
||||
},
|
||||
interface IMsisdnAuthEntryState {
|
||||
token: string;
|
||||
requestingToken: boolean;
|
||||
errorText: string;
|
||||
}
|
||||
|
||||
propTypes: {
|
||||
inputs: PropTypes.shape({
|
||||
phoneCountry: PropTypes.string,
|
||||
phoneNumber: PropTypes.string,
|
||||
}),
|
||||
fail: PropTypes.func,
|
||||
clientSecret: PropTypes.func,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
matrixClient: PropTypes.object,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
@replaceableComponent("views.auth.MsisdnAuthEntry")
|
||||
export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsisdnAuthEntryState> {
|
||||
static LOGIN_TYPE = AuthType.Msisdn;
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
private submitUrl: string;
|
||||
private sid: string;
|
||||
private msisdn: string;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
token: '',
|
||||
requestingToken: false,
|
||||
errorText: '',
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
|
||||
this._submitUrl = null;
|
||||
this._sid = null;
|
||||
this._msisdn = null;
|
||||
this._tokenBox = null;
|
||||
|
||||
this.setState({requestingToken: true});
|
||||
this._requestMsisdnToken().catch((e) => {
|
||||
this.setState({ requestingToken: true });
|
||||
this.requestMsisdnToken().catch((e) => {
|
||||
this.props.fail(e);
|
||||
}).finally(() => {
|
||||
this.setState({requestingToken: false});
|
||||
this.setState({ requestingToken: false });
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
/*
|
||||
* Requests a verification token by SMS.
|
||||
*/
|
||||
_requestMsisdnToken: function() {
|
||||
private requestMsisdnToken(): Promise<void> {
|
||||
return this.props.matrixClient.requestRegisterMsisdnToken(
|
||||
this.props.inputs.phoneCountry,
|
||||
this.props.inputs.phoneNumber,
|
||||
this.props.clientSecret,
|
||||
1, // TODO: Multiple send attempts?
|
||||
).then((result) => {
|
||||
this._submitUrl = result.submit_url;
|
||||
this._sid = result.sid;
|
||||
this._msisdn = result.msisdn;
|
||||
this.submitUrl = result.submit_url;
|
||||
this.sid = result.sid;
|
||||
this.msisdn = result.msisdn;
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onTokenChange: function(e) {
|
||||
private onTokenChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
token: e.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onFormSubmit: async function(e) {
|
||||
private onFormSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (this.state.token == '') return;
|
||||
|
||||
@@ -509,35 +551,24 @@ export const MsisdnAuthEntry = createReactClass({
|
||||
});
|
||||
|
||||
try {
|
||||
const requiresIdServerParam =
|
||||
await this.props.matrixClient.doesServerRequireIdServerParam();
|
||||
let result;
|
||||
if (this._submitUrl) {
|
||||
if (this.submitUrl) {
|
||||
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
|
||||
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
|
||||
);
|
||||
} else if (requiresIdServerParam) {
|
||||
result = await this.props.matrixClient.submitMsisdnToken(
|
||||
this._sid, this.props.clientSecret, this.state.token,
|
||||
this.submitUrl, this.sid, this.props.clientSecret, this.state.token,
|
||||
);
|
||||
} else {
|
||||
throw new Error("The registration with MSISDN flow is misconfigured");
|
||||
}
|
||||
if (result.success) {
|
||||
const creds = {
|
||||
sid: this._sid,
|
||||
sid: this.sid,
|
||||
client_secret: this.props.clientSecret,
|
||||
};
|
||||
if (requiresIdServerParam) {
|
||||
const idServerParsedUrl = url.parse(
|
||||
this.props.matrixClient.getIdentityServerUrl(),
|
||||
);
|
||||
creds.id_server = idServerParsedUrl.host;
|
||||
}
|
||||
this.props.submitAuthDict({
|
||||
type: MsisdnAuthEntry.LOGIN_TYPE,
|
||||
type: AuthType.Msisdn,
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/riot-web/issues/10312
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
threepid_creds: creds,
|
||||
threepidCreds: creds,
|
||||
});
|
||||
@@ -550,15 +581,14 @@ export const MsisdnAuthEntry = createReactClass({
|
||||
this.props.fail(e);
|
||||
console.log("Failed to submit msisdn token");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (this.state.requestingToken) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
return <Spinner />;
|
||||
} else {
|
||||
const enableSubmit = Boolean(this.state.token);
|
||||
const submitClasses = classnames({
|
||||
const submitClasses = classNames({
|
||||
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
|
||||
mx_GeneralButton: true,
|
||||
});
|
||||
@@ -573,16 +603,16 @@ export const MsisdnAuthEntry = createReactClass({
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("A text message has been sent to %(msisdn)s",
|
||||
{ msisdn: <i>{ this._msisdn }</i> },
|
||||
{ msisdn: <i>{ this.msisdn }</i> },
|
||||
) }
|
||||
</p>
|
||||
<p>{ _t("Please enter the code it contains:") }</p>
|
||||
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
|
||||
<form onSubmit={this._onFormSubmit}>
|
||||
<form onSubmit={this.onFormSubmit}>
|
||||
<input type="text"
|
||||
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
|
||||
value={this.state.token}
|
||||
onChange={this._onTokenChange}
|
||||
onChange={this.onTokenChange}
|
||||
aria-label={ _t("Code")}
|
||||
/>
|
||||
<br />
|
||||
@@ -596,60 +626,88 @@ export const MsisdnAuthEntry = createReactClass({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SSOAuthEntry extends React.Component {
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
authSessionId: PropTypes.string.isRequired,
|
||||
loginType: PropTypes.string.isRequired,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
continueText: PropTypes.string,
|
||||
continueKind: PropTypes.string,
|
||||
onCancel: PropTypes.func,
|
||||
};
|
||||
interface ISSOAuthEntryProps extends IAuthEntryProps {
|
||||
continueText?: string;
|
||||
continueKind?: string;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
static LOGIN_TYPE = "m.login.sso";
|
||||
static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso";
|
||||
interface ISSOAuthEntryState {
|
||||
phase: number;
|
||||
attemptFailed: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.auth.SSOAuthEntry")
|
||||
export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEntryState> {
|
||||
static LOGIN_TYPE = AuthType.Sso;
|
||||
static UNSTABLE_LOGIN_TYPE = AuthType.SsoUnstable;
|
||||
|
||||
static PHASE_PREAUTH = 1; // button to start SSO
|
||||
static PHASE_POSTAUTH = 2; // button to confirm SSO completed
|
||||
|
||||
_ssoUrl: string;
|
||||
private ssoUrl: string;
|
||||
private popupWindow: Window;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// We actually send the user through fallback auth so we don't have to
|
||||
// deal with a redirect back to us, losing application context.
|
||||
this._ssoUrl = props.matrixClient.getFallbackAuthUrl(
|
||||
this.ssoUrl = props.matrixClient.getFallbackAuthUrl(
|
||||
this.props.loginType,
|
||||
this.props.authSessionId,
|
||||
);
|
||||
|
||||
this.popupWindow = null;
|
||||
window.addEventListener("message", this.onReceiveMessage);
|
||||
|
||||
this.state = {
|
||||
phase: SSOAuthEntry.PHASE_PREAUTH,
|
||||
attemptFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
|
||||
}
|
||||
|
||||
onStartAuthClick = () => {
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("message", this.onReceiveMessage);
|
||||
if (this.popupWindow) {
|
||||
this.popupWindow.close();
|
||||
this.popupWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
public attemptFailed = () => {
|
||||
this.setState({
|
||||
attemptFailed: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onReceiveMessage = (event: MessageEvent) => {
|
||||
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
|
||||
if (this.popupWindow) {
|
||||
this.popupWindow.close();
|
||||
this.popupWindow = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onStartAuthClick = () => {
|
||||
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost
|
||||
// certainly will need to open the thing in a new tab to avoid losing application
|
||||
// context.
|
||||
|
||||
window.open(this._ssoUrl, '_blank');
|
||||
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
|
||||
this.popupWindow = window.open(this.ssoUrl, "_blank");
|
||||
this.setState({ phase: SSOAuthEntry.PHASE_POSTAUTH });
|
||||
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
|
||||
};
|
||||
|
||||
onConfirmClick = () => {
|
||||
private onConfirmClick = () => {
|
||||
this.props.submitAuthDict({});
|
||||
};
|
||||
|
||||
@@ -677,53 +735,63 @@ export class SSOAuthEntry extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
return <div className='mx_InteractiveAuthEntryComponents_sso_buttons'>
|
||||
{cancelButton}
|
||||
{continueButton}
|
||||
</div>;
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ this.props.errorText }
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.attemptFailed) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ _t("Something went wrong in confirming your identity. Cancel and try again.") }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
{ errorSection }
|
||||
<div className="mx_InteractiveAuthEntryComponents_sso_buttons">
|
||||
{cancelButton}
|
||||
{continueButton}
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
export const FallbackAuthEntry = createReactClass({
|
||||
displayName: 'FallbackAuthEntry',
|
||||
@replaceableComponent("views.auth.FallbackAuthEntry")
|
||||
export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
|
||||
private popupWindow: Window;
|
||||
private fallbackButton = createRef<HTMLAnchorElement>();
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
authSessionId: PropTypes.string.isRequired,
|
||||
loginType: PropTypes.string.isRequired,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
componentDidMount: function() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
// we have to make the user click a button, as browsers will block
|
||||
// the popup if we open it immediately.
|
||||
this._popupWindow = null;
|
||||
window.addEventListener("message", this._onReceiveMessage);
|
||||
this.popupWindow = null;
|
||||
window.addEventListener("message", this.onReceiveMessage);
|
||||
}
|
||||
|
||||
this._fallbackButton = createRef();
|
||||
},
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
window.removeEventListener("message", this._onReceiveMessage);
|
||||
if (this._popupWindow) {
|
||||
this._popupWindow.close();
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("message", this.onReceiveMessage);
|
||||
if (this.popupWindow) {
|
||||
this.popupWindow.close();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
focus: function() {
|
||||
if (this._fallbackButton.current) {
|
||||
this._fallbackButton.current.focus();
|
||||
public focus = () => {
|
||||
if (this.fallbackButton.current) {
|
||||
this.fallbackButton.current.focus();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onShowFallbackClick: function(e) {
|
||||
private onShowFallbackClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -731,20 +799,19 @@ export const FallbackAuthEntry = createReactClass({
|
||||
this.props.loginType,
|
||||
this.props.authSessionId,
|
||||
);
|
||||
this._popupWindow = window.open(url);
|
||||
this._popupWindow.opener = null;
|
||||
},
|
||||
this.popupWindow = window.open(url, "_blank");
|
||||
};
|
||||
|
||||
_onReceiveMessage: function(event) {
|
||||
private onReceiveMessage = (event: MessageEvent) => {
|
||||
if (
|
||||
event.data === "authDone" &&
|
||||
event.origin === this.props.matrixClient.getHomeserverUrl()
|
||||
) {
|
||||
this.props.submitAuthDict({});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
errorSection = (
|
||||
@@ -755,27 +822,31 @@ export const FallbackAuthEntry = createReactClass({
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<a href="" ref={this._fallbackButton} onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
|
||||
<a href="" ref={this.fallbackButton} onClick={this.onShowFallbackClick}>{
|
||||
_t("Start authentication")
|
||||
}</a>
|
||||
{errorSection}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const AuthEntryComponents = [
|
||||
PasswordAuthEntry,
|
||||
RecaptchaAuthEntry,
|
||||
EmailIdentityAuthEntry,
|
||||
MsisdnAuthEntry,
|
||||
TermsAuthEntry,
|
||||
SSOAuthEntry,
|
||||
];
|
||||
|
||||
export default function getEntryComponentForLoginType(loginType) {
|
||||
for (const c of AuthEntryComponents) {
|
||||
if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return FallbackAuthEntry;
|
||||
}
|
||||
|
||||
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
|
||||
switch (loginType) {
|
||||
case AuthType.Password:
|
||||
return PasswordAuthEntry;
|
||||
case AuthType.Recaptcha:
|
||||
return RecaptchaAuthEntry;
|
||||
case AuthType.Email:
|
||||
return EmailIdentityAuthEntry;
|
||||
case AuthType.Msisdn:
|
||||
return MsisdnAuthEntry;
|
||||
case AuthType.Terms:
|
||||
return TermsAuthEntry;
|
||||
case AuthType.Sso:
|
||||
case AuthType.SsoUnstable:
|
||||
return SSOAuthEntry;
|
||||
default:
|
||||
return FallbackAuthEntry;
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,12 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import {getCurrentLanguage} from "../../../languageHandler";
|
||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
import { getCurrentLanguage } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import * as sdk from '../../../index';
|
||||
import React from 'react';
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
|
||||
function onChange(newLang) {
|
||||
if (getCurrentLanguage() !== newLang) {
|
||||
@@ -28,12 +29,14 @@ function onChange(newLang) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function LanguageSelector() {
|
||||
export default function LanguageSelector({ disabled }) {
|
||||
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
|
||||
|
||||
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
|
||||
return <LanguageDropdown className="mx_AuthBody_language"
|
||||
return <LanguageDropdown
|
||||
className="mx_AuthBody_language"
|
||||
onOptionChange={onChange}
|
||||
value={getCurrentLanguage()}
|
||||
disabled={disabled}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -1,123 +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 from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import ServerConfig from "./ServerConfig";
|
||||
|
||||
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
|
||||
// TODO: TravisR - Can this extend ServerConfig for most things?
|
||||
|
||||
/*
|
||||
* Configure the Modular server name.
|
||||
*
|
||||
* This is a variant of ServerConfig with only the HS field and different body
|
||||
* text that is specific to the Modular case.
|
||||
*/
|
||||
export default class ModularServerConfig extends ServerConfig {
|
||||
static propTypes = ServerConfig.propTypes;
|
||||
|
||||
async validateAndApplyServer(hsUrl, isUrl) {
|
||||
// Always try and use the defaults first
|
||||
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
|
||||
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
hsUrl,
|
||||
isUrl,
|
||||
busy: true,
|
||||
errorText: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
let message = _t("Unable to validate homeserver/identity server");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: message,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async validateServer() {
|
||||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
|
||||
}
|
||||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const submitButton = this.props.submitText
|
||||
? <AccessibleButton
|
||||
element="button"
|
||||
type="submit"
|
||||
className={this.props.submitClass}
|
||||
onClick={this.onSubmit}
|
||||
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx_ServerConfig">
|
||||
<h3>{_t("Your Modular 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>.",
|
||||
{}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</a>,
|
||||
},
|
||||
)}
|
||||
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
|
||||
<div className="mx_ServerConfig_fields">
|
||||
<Field
|
||||
id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Server Name")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
/>
|
||||
</div>
|
||||
{submitButton}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
121
src/components/views/auth/PassphraseField.tsx
Normal file
121
src/components/views/auth/PassphraseField.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
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, { PureComponent, RefCallback, RefObject } from "react";
|
||||
import classNames from "classnames";
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import Field, { IInputProps } from "../elements/Field";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps extends Omit<IInputProps, "onValidate"> {
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
minScore: 0 | 1 | 2 | 3 | 4;
|
||||
value: string;
|
||||
fieldRef?: RefCallback<Field> | RefObject<Field>;
|
||||
|
||||
label?: string;
|
||||
labelEnterPassword?: string;
|
||||
labelStrongPassword?: string;
|
||||
labelAllowedButUnsafe?: string;
|
||||
|
||||
onChange(ev: React.FormEvent<HTMLElement>);
|
||||
onValidate(result: IValidationResult);
|
||||
}
|
||||
|
||||
@replaceableComponent("views.auth.PassphraseField")
|
||||
class PassphraseField extends PureComponent<IProps> {
|
||||
static defaultProps = {
|
||||
label: _td("Password"),
|
||||
labelEnterPassword: _td("Enter password"),
|
||||
labelStrongPassword: _td("Nice, strong password!"),
|
||||
labelAllowedButUnsafe: _td("Password is allowed, but unsafe"),
|
||||
};
|
||||
|
||||
public readonly validate = withValidation<this, zxcvbn.ZXCVBNResult>({
|
||||
description: function(complexity) {
|
||||
const score = complexity ? complexity.score : 0;
|
||||
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
|
||||
},
|
||||
deriveData: async ({ value }) => {
|
||||
if (!value) return null;
|
||||
const { scorePassword } = await import('../../../utils/PasswordScorer');
|
||||
return scorePassword(value);
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t(this.props.labelEnterPassword),
|
||||
},
|
||||
{
|
||||
key: "complexity",
|
||||
test: async function({ value }, complexity) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const safe = complexity.score >= this.props.minScore;
|
||||
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
|
||||
return allowUnsafe || safe;
|
||||
},
|
||||
valid: function(complexity) {
|
||||
// Unsafe passwords that are valid are only possible through a
|
||||
// configuration flag. We'll print some helper text to signal
|
||||
// to the user that their password is allowed, but unsafe.
|
||||
if (complexity.score >= this.props.minScore) {
|
||||
return _t(this.props.labelStrongPassword);
|
||||
}
|
||||
return _t(this.props.labelAllowedButUnsafe);
|
||||
},
|
||||
invalid: function(complexity) {
|
||||
if (!complexity) {
|
||||
return null;
|
||||
}
|
||||
const { feedback } = complexity;
|
||||
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
onValidate = async (fieldState: IFieldState) => {
|
||||
const result = await this.validate(fieldState);
|
||||
this.props.onValidate(result);
|
||||
return result;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <Field
|
||||
id={this.props.id}
|
||||
autoFocus={this.props.autoFocus}
|
||||
className={classNames("mx_PassphraseField", this.props.className)}
|
||||
ref={this.props.fieldRef}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={_t(this.props.label)}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
onValidate={this.onValidate}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
export default PassphraseField;
|
||||
@@ -1,344 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a username/password form.
|
||||
*/
|
||||
export default class PasswordLogin extends React.Component {
|
||||
static propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired, // fn(username, password)
|
||||
onError: PropTypes.func,
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
onForgotPasswordClick: PropTypes.func, // fn()
|
||||
initialUsername: PropTypes.string,
|
||||
initialPhoneCountry: PropTypes.string,
|
||||
initialPhoneNumber: PropTypes.string,
|
||||
initialPassword: PropTypes.string,
|
||||
onUsernameChanged: PropTypes.func,
|
||||
onPhoneCountryChanged: PropTypes.func,
|
||||
onPhoneNumberChanged: PropTypes.func,
|
||||
onPasswordChanged: PropTypes.func,
|
||||
loginIncorrect: PropTypes.bool,
|
||||
disableSubmit: PropTypes.bool,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onError: function() {},
|
||||
onEditServerDetailsClick: null,
|
||||
onUsernameChanged: function() {},
|
||||
onUsernameBlur: function() {},
|
||||
onPasswordChanged: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
onPhoneNumberBlur: function() {},
|
||||
initialUsername: "",
|
||||
initialPhoneCountry: "",
|
||||
initialPhoneNumber: "",
|
||||
initialPassword: "",
|
||||
loginIncorrect: false,
|
||||
disableSubmit: false,
|
||||
};
|
||||
|
||||
static LOGIN_FIELD_EMAIL = "login_field_email";
|
||||
static LOGIN_FIELD_MXID = "login_field_mxid";
|
||||
static LOGIN_FIELD_PHONE = "login_field_phone";
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: this.props.initialUsername,
|
||||
password: this.props.initialPassword,
|
||||
phoneCountry: this.props.initialPhoneCountry,
|
||||
phoneNumber: this.props.initialPhoneNumber,
|
||||
loginType: PasswordLogin.LOGIN_FIELD_MXID,
|
||||
};
|
||||
|
||||
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
|
||||
this.onSubmitForm = this.onSubmitForm.bind(this);
|
||||
this.onUsernameChanged = this.onUsernameChanged.bind(this);
|
||||
this.onUsernameBlur = this.onUsernameBlur.bind(this);
|
||||
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
|
||||
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
|
||||
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
|
||||
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
|
||||
this.onPasswordChanged = this.onPasswordChanged.bind(this);
|
||||
this.isLoginEmpty = this.isLoginEmpty.bind(this);
|
||||
}
|
||||
|
||||
onForgotPasswordClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onForgotPasswordClick();
|
||||
}
|
||||
|
||||
onSubmitForm(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
let username = ''; // XXX: Synapse breaks if you send null here:
|
||||
let phoneCountry = null;
|
||||
let phoneNumber = null;
|
||||
let error;
|
||||
|
||||
switch (this.state.loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
username = this.state.username;
|
||||
if (!username) {
|
||||
error = _t('The email field must not be blank.');
|
||||
}
|
||||
break;
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
username = this.state.username;
|
||||
if (!username) {
|
||||
error = _t('The username field must not be blank.');
|
||||
}
|
||||
break;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE:
|
||||
phoneCountry = this.state.phoneCountry;
|
||||
phoneNumber = this.state.phoneNumber;
|
||||
if (!phoneNumber) {
|
||||
error = _t('The phone number field must not be blank.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this.props.onError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.password) {
|
||||
this.props.onError(_t('The password field must not be blank.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onSubmit(
|
||||
username,
|
||||
phoneCountry,
|
||||
phoneNumber,
|
||||
this.state.password,
|
||||
);
|
||||
}
|
||||
|
||||
onUsernameChanged(ev) {
|
||||
this.setState({username: ev.target.value});
|
||||
this.props.onUsernameChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onUsernameBlur(ev) {
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
}
|
||||
|
||||
onLoginTypeChange(ev) {
|
||||
const loginType = ev.target.value;
|
||||
this.props.onError(null); // send a null error to clear any error messages
|
||||
this.setState({
|
||||
loginType: loginType,
|
||||
username: "", // Reset because email and username use the same state
|
||||
});
|
||||
}
|
||||
|
||||
onPhoneCountryChanged(country) {
|
||||
this.setState({
|
||||
phoneCountry: country.iso2,
|
||||
phonePrefix: country.prefix,
|
||||
});
|
||||
this.props.onPhoneCountryChanged(country.iso2);
|
||||
}
|
||||
|
||||
onPhoneNumberChanged(ev) {
|
||||
this.setState({phoneNumber: ev.target.value});
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onPhoneNumberBlur(ev) {
|
||||
this.props.onPhoneNumberBlur(ev.target.value);
|
||||
}
|
||||
|
||||
onPasswordChanged(ev) {
|
||||
this.setState({password: ev.target.value});
|
||||
this.props.onPasswordChanged(ev.target.value);
|
||||
}
|
||||
|
||||
renderLoginField(loginType) {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
const classes = {};
|
||||
|
||||
switch (loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
classes.error = this.props.loginIncorrect && !this.state.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="email_input"
|
||||
type="text"
|
||||
label={_t("Email")}
|
||||
placeholder="joe@example.com"
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus
|
||||
/>;
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
classes.error = this.props.loginIncorrect && !this.state.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus
|
||||
/>;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE: {
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
|
||||
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.state.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChanged}
|
||||
/>;
|
||||
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.state.phoneNumber}
|
||||
prefix={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoginEmpty() {
|
||||
switch (this.state.loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
return !this.state.username;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE:
|
||||
return !this.state.phoneCountry || !this.state.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
|
||||
let forgotPasswordJsx;
|
||||
|
||||
if (this.props.onForgotPasswordClick) {
|
||||
forgotPasswordJsx = <span>
|
||||
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
|
||||
a: sub => <a className="mx_Login_forgot"
|
||||
onClick={this.onForgotPasswordClick}
|
||||
href="#"
|
||||
>
|
||||
{sub}
|
||||
</a>,
|
||||
})}
|
||||
</span>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
||||
const loginField = this.renderLoginField(this.state.loginType);
|
||||
|
||||
let loginType;
|
||||
if (!SdkConfig.get().disable_3pid_login) {
|
||||
loginType = (
|
||||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
onChange={this.onLoginTypeChange}
|
||||
disabled={this.props.disableSubmit}
|
||||
>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_MXID}
|
||||
value={PasswordLogin.LOGIN_FIELD_MXID}
|
||||
>
|
||||
{_t('Username')}
|
||||
</option>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_EMAIL}
|
||||
value={PasswordLogin.LOGIN_FIELD_EMAIL}
|
||||
>
|
||||
{_t('Email address')}
|
||||
</option>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_PHONE}
|
||||
value={PasswordLogin.LOGIN_FIELD_PHONE}
|
||||
>
|
||||
{_t('Phone')}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SignInToText serverConfig={this.props.serverConfig}
|
||||
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChanged}
|
||||
disabled={this.props.disableSubmit}
|
||||
/>
|
||||
{forgotPasswordJsx}
|
||||
<input className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Sign in')}
|
||||
disabled={this.props.disableSubmit}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
487
src/components/views/auth/PasswordLogin.tsx
Normal file
487
src/components/views/auth/PasswordLogin.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
Copyright 2015, 2016, 2017, 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 classNames from 'classnames';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import withValidation from "../elements/Validation";
|
||||
import * as Email from "../../../email";
|
||||
import Field from "../elements/Field";
|
||||
import CountryDropdown from "./CountryDropdown";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
||||
interface IProps {
|
||||
username: string; // also used for email address
|
||||
phoneCountry: string;
|
||||
phoneNumber: string;
|
||||
|
||||
serverConfig: ValidatedServerConfig;
|
||||
loginIncorrect?: boolean;
|
||||
disableSubmit?: boolean;
|
||||
busy?: boolean;
|
||||
|
||||
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
|
||||
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
|
||||
onUsernameChanged?(username: string): void;
|
||||
onUsernameBlur?(username: string): void;
|
||||
onPhoneCountryChanged?(phoneCountry: string): void;
|
||||
onPhoneNumberChanged?(phoneNumber: string): void;
|
||||
onForgotPasswordClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
fieldValid: Partial<Record<LoginField, boolean>>;
|
||||
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone;
|
||||
password: "";
|
||||
}
|
||||
|
||||
enum LoginField {
|
||||
Email = "login_field_email",
|
||||
MatrixId = "login_field_mxid",
|
||||
Phone = "login_field_phone",
|
||||
Password = "login_field_phone",
|
||||
}
|
||||
|
||||
/*
|
||||
* A pure UI component which displays a username/password form.
|
||||
* The email/username/phone fields are fully-controlled, the password field is not.
|
||||
*/
|
||||
@replaceableComponent("views.auth.PasswordLogin")
|
||||
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onUsernameChanged: function() {},
|
||||
onUsernameBlur: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
loginIncorrect: false,
|
||||
disableSubmit: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
loginType: LoginField.MatrixId,
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
private onForgotPasswordClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onForgotPasswordClick();
|
||||
};
|
||||
|
||||
private onSubmitForm = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let username = ''; // XXX: Synapse breaks if you send null here:
|
||||
let phoneCountry = null;
|
||||
let phoneNumber = null;
|
||||
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
username = this.props.username;
|
||||
break;
|
||||
case LoginField.Phone:
|
||||
phoneCountry = this.props.phoneCountry;
|
||||
phoneNumber = this.props.phoneNumber;
|
||||
break;
|
||||
}
|
||||
|
||||
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
|
||||
};
|
||||
|
||||
private onUsernameChanged = ev => {
|
||||
this.props.onUsernameChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onUsernameFocus = () => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_focus");
|
||||
}
|
||||
};
|
||||
|
||||
private onUsernameBlur = ev => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_blur");
|
||||
}
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
};
|
||||
|
||||
private onLoginTypeChange = ev => {
|
||||
const loginType = ev.target.value;
|
||||
this.setState({ loginType });
|
||||
this.props.onUsernameChanged(""); // Reset because email and username use the same state
|
||||
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
|
||||
};
|
||||
|
||||
private onPhoneCountryChanged = country => {
|
||||
this.props.onPhoneCountryChanged(country.iso2);
|
||||
};
|
||||
|
||||
private onPhoneNumberChanged = ev => {
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onPhoneNumberFocus = () => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
|
||||
};
|
||||
|
||||
private onPhoneNumberBlur = ev => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
|
||||
};
|
||||
|
||||
private onPasswordChanged = ev => {
|
||||
this.setState({ password: ev.target.value });
|
||||
};
|
||||
|
||||
private async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
this.state.loginType,
|
||||
LoginField.Password,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
// values for required fields.
|
||||
for (const fieldID of fieldIDsInDisplayOrder) {
|
||||
const field = this[fieldID];
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
// We must wait for these validations to finish before queueing
|
||||
// up the setState below so our setState goes in the queue after
|
||||
// all the setStates from these validate calls (that's how we
|
||||
// know they've finished).
|
||||
await field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise<void>(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
|
||||
|
||||
if (!invalidField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus the first invalid field and show feedback in the stricter mode
|
||||
// that no longer allows empty values for required fields.
|
||||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
private allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private findFirstInvalidField(fieldIDs: LoginField[]) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private markFieldValid(fieldID: LoginField, valid: boolean) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldValid,
|
||||
});
|
||||
}
|
||||
|
||||
private validateUsernameRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter username"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onUsernameValidate = async (fieldState) => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(LoginField.MatrixId, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validateEmailRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address"),
|
||||
}, {
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onEmailValidate = async (fieldState) => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(LoginField.Email, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePhoneNumberRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number"),
|
||||
}, {
|
||||
key: "number",
|
||||
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
|
||||
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPhoneNumberValidate = async (fieldState) => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePasswordRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter password"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPasswordValidate = async (fieldState) => {
|
||||
const result = await this.validatePasswordRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
|
||||
const classes = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
switch (loginType) {
|
||||
case LoginField.Email:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="email_input"
|
||||
type="text"
|
||||
label={_t("Email")}
|
||||
placeholder="joe@example.com"
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onEmailValidate}
|
||||
ref={field => this[LoginField.Email] = field}
|
||||
/>;
|
||||
case LoginField.MatrixId:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
placeholder={_t("Username").toLocaleLowerCase()}
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onUsernameValidate}
|
||||
ref={field => this[LoginField.MatrixId] = field}
|
||||
/>;
|
||||
case LoginField.Phone: {
|
||||
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
|
||||
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.props.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChanged}
|
||||
/>;
|
||||
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.props.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onFocus={this.onPhoneNumberFocus}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isLoginEmpty() {
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
return !this.props.username;
|
||||
case LoginField.Phone:
|
||||
return !this.props.phoneCountry || !this.props.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let forgotPasswordJsx;
|
||||
|
||||
if (this.props.onForgotPasswordClick) {
|
||||
forgotPasswordJsx = <AccessibleButton
|
||||
className="mx_Login_forgot"
|
||||
disabled={this.props.busy}
|
||||
kind="link"
|
||||
onClick={this.onForgotPasswordClick}
|
||||
>
|
||||
{_t("Forgot password?")}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
||||
// If login is empty, autoFocus login, otherwise autoFocus password.
|
||||
// this is for when auto server discovery remounts us when the user tries to tab from username to password
|
||||
const autoFocusPassword = !this.isLoginEmpty();
|
||||
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
|
||||
|
||||
let loginType;
|
||||
if (!SdkConfig.get().disable_3pid_login) {
|
||||
loginType = (
|
||||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
onChange={this.onLoginTypeChange}
|
||||
disabled={this.props.disableSubmit}
|
||||
>
|
||||
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
|
||||
{_t('Username')}
|
||||
</option>
|
||||
<option
|
||||
key={LoginField.Email}
|
||||
value={LoginField.Email}
|
||||
>
|
||||
{_t('Email address')}
|
||||
</option>
|
||||
<option key={LoginField.Password} value={LoginField.Password}>
|
||||
{_t('Phone')}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChanged}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocusPassword}
|
||||
onValidate={this.onPasswordValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>
|
||||
{forgotPasswordJsx}
|
||||
{ !this.props.busy && <input className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Sign in')}
|
||||
disabled={this.props.disableSubmit}
|
||||
/> }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,636 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
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 * as sdk from '../../../index';
|
||||
import * as Email from '../../../email';
|
||||
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
|
||||
import withValidation from '../elements/Validation';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
const FIELD_EMAIL = 'field_email';
|
||||
const FIELD_PHONE_NUMBER = 'field_phone_number';
|
||||
const FIELD_USERNAME = 'field_username';
|
||||
const FIELD_PASSWORD = 'field_password';
|
||||
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
|
||||
|
||||
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a registration form.
|
||||
*/
|
||||
export default createReactClass({
|
||||
displayName: 'RegistrationForm',
|
||||
|
||||
propTypes: {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail: PropTypes.string,
|
||||
defaultPhoneCountry: PropTypes.string,
|
||||
defaultPhoneNumber: PropTypes.string,
|
||||
defaultUsername: PropTypes.string,
|
||||
defaultPassword: PropTypes.string,
|
||||
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
canSubmit: PropTypes.bool,
|
||||
serverRequiresIdServer: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onValidationChange: console.error,
|
||||
canSubmit: true,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: this.props.defaultPhoneCountry,
|
||||
username: this.props.defaultUsername || "",
|
||||
email: this.props.defaultEmail || "",
|
||||
phoneNumber: this.props.defaultPhoneNumber || "",
|
||||
password: this.props.defaultPassword || "",
|
||||
passwordConfirm: "",
|
||||
passwordComplexity: null,
|
||||
passwordSafe: false,
|
||||
};
|
||||
},
|
||||
|
||||
onSubmit: async function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!this.props.canSubmit) return;
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
if (this.state.email === '') {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
|
||||
let desc;
|
||||
if (this.props.serverRequiresIdServer && !haveIs) {
|
||||
desc = _t(
|
||||
"No identity server is configured so you cannot add an email address in order to " +
|
||||
"reset your password in the future.",
|
||||
);
|
||||
} else {
|
||||
desc = _t(
|
||||
"If you don't specify an email address, you won't be able to reset your password. " +
|
||||
"Are you sure?",
|
||||
);
|
||||
}
|
||||
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description: desc,
|
||||
button: _t("Continue"),
|
||||
onFinished: function(confirmed) {
|
||||
if (confirmed) {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
},
|
||||
|
||||
_doSubmit: function(ev) {
|
||||
const email = this.state.email.trim();
|
||||
const promise = this.props.onRegisterClick({
|
||||
username: this.state.username.trim(),
|
||||
password: this.state.password.trim(),
|
||||
email: email,
|
||||
phoneCountry: this.state.phoneCountry,
|
||||
phoneNumber: this.state.phoneNumber,
|
||||
});
|
||||
|
||||
if (promise) {
|
||||
ev.target.disabled = true;
|
||||
promise.finally(function() {
|
||||
ev.target.disabled = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
FIELD_USERNAME,
|
||||
FIELD_PASSWORD,
|
||||
FIELD_PASSWORD_CONFIRM,
|
||||
FIELD_EMAIL,
|
||||
FIELD_PHONE_NUMBER,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
// values for required fields.
|
||||
for (const fieldID of fieldIDsInDisplayOrder) {
|
||||
const field = this[fieldID];
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
// We must wait for these validations to finish before queueing
|
||||
// up the setState below so our setState goes in the queue after
|
||||
// all the setStates from these validate calls (that's how we
|
||||
// know they've finished).
|
||||
await field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
|
||||
|
||||
if (!invalidField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus the first invalid field and show feedback in the stricter mode
|
||||
// that no longer allows empty values for required fields.
|
||||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if all fields were valid last time they were validated.
|
||||
*/
|
||||
allFieldsValid: function() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
findFirstInvalidField(fieldIDs) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
markFieldValid: function(fieldID, valid) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldValid,
|
||||
});
|
||||
},
|
||||
|
||||
onEmailChange(ev) {
|
||||
this.setState({
|
||||
email: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
async onEmailValidate(fieldState) {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(FIELD_EMAIL, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validateEmailRules: withValidation({
|
||||
description: () => _t("Use an email address to recover your account"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: function({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address (required on this homeserver)"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onPasswordChange(ev) {
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
async onPasswordValidate(fieldState) {
|
||||
const result = await this.validatePasswordRules(fieldState);
|
||||
this.markFieldValid(FIELD_PASSWORD, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validatePasswordRules: withValidation({
|
||||
description: function() {
|
||||
const complexity = this.state.passwordComplexity;
|
||||
const score = complexity ? complexity.score : 0;
|
||||
return <progress
|
||||
className="mx_AuthBody_passwordScore"
|
||||
max={PASSWORD_MIN_SCORE}
|
||||
value={score}
|
||||
/>;
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Enter password"),
|
||||
},
|
||||
{
|
||||
key: "complexity",
|
||||
test: async function({ value }) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const { scorePassword } = await import('../../../utils/PasswordScorer');
|
||||
const complexity = scorePassword(value);
|
||||
const safe = complexity.score >= PASSWORD_MIN_SCORE;
|
||||
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
|
||||
this.setState({
|
||||
passwordComplexity: complexity,
|
||||
passwordSafe: safe,
|
||||
});
|
||||
return allowUnsafe || safe;
|
||||
},
|
||||
valid: function() {
|
||||
// Unsafe passwords that are valid are only possible through a
|
||||
// configuration flag. We'll print some helper text to signal
|
||||
// to the user that their password is allowed, but unsafe.
|
||||
if (!this.state.passwordSafe) {
|
||||
return _t("Password is allowed, but unsafe");
|
||||
}
|
||||
return _t("Nice, strong password!");
|
||||
},
|
||||
invalid: function() {
|
||||
const complexity = this.state.passwordComplexity;
|
||||
if (!complexity) {
|
||||
return null;
|
||||
}
|
||||
const { feedback } = complexity;
|
||||
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onPasswordConfirmChange(ev) {
|
||||
this.setState({
|
||||
passwordConfirm: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
async onPasswordConfirmValidate(fieldState) {
|
||||
const result = await this.validatePasswordConfirmRules(fieldState);
|
||||
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validatePasswordConfirmRules: withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Confirm password"),
|
||||
},
|
||||
{
|
||||
key: "match",
|
||||
test: function({ value }) {
|
||||
return !value || value === this.state.password;
|
||||
},
|
||||
invalid: () => _t("Passwords don't match"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onPhoneCountryChange(newVal) {
|
||||
this.setState({
|
||||
phoneCountry: newVal.iso2,
|
||||
phonePrefix: newVal.prefix,
|
||||
});
|
||||
},
|
||||
|
||||
onPhoneNumberChange(ev) {
|
||||
this.setState({
|
||||
phoneNumber: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
async onPhoneNumberValidate(fieldState) {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validatePhoneNumberRules: withValidation({
|
||||
description: () => _t("Other users can invite you to rooms using your contact details"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: function({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number (required on this homeserver)"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || phoneNumberLooksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid phone number"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onUsernameChange(ev) {
|
||||
this.setState({
|
||||
username: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
async onUsernameValidate(fieldState) {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(FIELD_USERNAME, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validateUsernameRules: withValidation({
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Enter username"),
|
||||
},
|
||||
{
|
||||
key: "safeLocalpart",
|
||||
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
|
||||
invalid: () => _t("Some characters not allowed"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
/**
|
||||
* A step is required if all flows include that step.
|
||||
*
|
||||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is required
|
||||
*/
|
||||
_authStepIsRequired(step) {
|
||||
return this.props.flows.every((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* A step is used if any flows include that step.
|
||||
*
|
||||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is used
|
||||
*/
|
||||
_authStepIsUsed(step) {
|
||||
return this.props.flows.some((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
},
|
||||
|
||||
_showEmail() {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.email.identity')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
_showPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
!threePidLogin ||
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.msisdn')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
renderEmail() {
|
||||
if (!this._showEmail()) {
|
||||
return null;
|
||||
}
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
|
||||
_t("Email") :
|
||||
_t("Email (optional)");
|
||||
return <Field
|
||||
ref={field => this[FIELD_EMAIL] = field}
|
||||
type="text"
|
||||
label={emailPlaceholder}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}
|
||||
onValidate={this.onEmailValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderPassword() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_password"
|
||||
ref={field => this[FIELD_PASSWORD] = field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={_t("Password")}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChange}
|
||||
onValidate={this.onPasswordValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderPasswordConfirm() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_passwordConfirm"
|
||||
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={_t("Confirm")}
|
||||
value={this.state.passwordConfirm}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderPhoneNumber() {
|
||||
if (!this._showPhoneNumber()) {
|
||||
return null;
|
||||
}
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
|
||||
_t("Phone") :
|
||||
_t("Phone (optional)");
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.state.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChange}
|
||||
/>;
|
||||
return <Field
|
||||
ref={field => this[FIELD_PHONE_NUMBER] = field}
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
value={this.state.phoneNumber}
|
||||
prefix={phoneCountry}
|
||||
onChange={this.onPhoneNumberChange}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderUsername() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_username"
|
||||
ref={field => this[FIELD_USERNAME] = field}
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
label={_t("Username")}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChange}
|
||||
onValidate={this.onUsernameValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
if (this.props.onEditServerDetailsClick) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.props.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
const registerButton = (
|
||||
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
|
||||
);
|
||||
|
||||
let emailHelperText = null;
|
||||
if (this._showEmail()) {
|
||||
if (this._showPhoneNumber()) {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email or phone to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
</div>;
|
||||
} else {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
let noIsText = null;
|
||||
if (this.props.serverRequiresIdServer && !haveIs) {
|
||||
noIsText = <div>
|
||||
{_t(
|
||||
"No identity server is configured so you cannot add an email address in order to " +
|
||||
"reset your password in the future.",
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderUsername()}
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderPassword()}
|
||||
{this.renderPasswordConfirm()}
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderEmail()}
|
||||
{this.renderPhoneNumber()}
|
||||
</div>
|
||||
{ emailHelperText }
|
||||
{ noIsText }
|
||||
{ registerButton }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
556
src/components/views/auth/RegistrationForm.tsx
Normal file
556
src/components/views/auth/RegistrationForm.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
/*
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016, 2017, 2018, 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 from 'react';
|
||||
|
||||
import * as Email from '../../../email';
|
||||
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
|
||||
import withValidation from '../elements/Validation';
|
||||
import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
|
||||
import PassphraseField from "./PassphraseField";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import Field from '../elements/Field';
|
||||
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import CountryDropdown from "./CountryDropdown";
|
||||
|
||||
enum RegistrationField {
|
||||
Email = "field_email",
|
||||
PhoneNumber = "field_phone_number",
|
||||
Username = "field_username",
|
||||
Password = "field_password",
|
||||
PasswordConfirm = "field_password_confirm",
|
||||
}
|
||||
|
||||
export const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
|
||||
|
||||
interface IProps {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail?: string;
|
||||
defaultPhoneCountry?: string;
|
||||
defaultPhoneNumber?: string;
|
||||
defaultUsername?: string;
|
||||
defaultPassword?: string;
|
||||
flows: {
|
||||
stages: string[];
|
||||
}[];
|
||||
serverConfig: ValidatedServerConfig;
|
||||
canSubmit?: boolean;
|
||||
|
||||
onRegisterClick(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
phoneCountry?: string;
|
||||
phoneNumber?: string;
|
||||
}): Promise<void>;
|
||||
onEditServerDetailsClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// Field error codes by field ID
|
||||
fieldValid: Partial<Record<RegistrationField, boolean>>;
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
passwordComplexity?: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* A pure UI component which displays a registration form.
|
||||
*/
|
||||
@replaceableComponent("views.auth.RegistrationForm")
|
||||
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onValidationChange: console.error,
|
||||
canSubmit: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
fieldValid: {},
|
||||
phoneCountry: this.props.defaultPhoneCountry,
|
||||
username: this.props.defaultUsername || "",
|
||||
email: this.props.defaultEmail || "",
|
||||
phoneNumber: this.props.defaultPhoneNumber || "",
|
||||
password: this.props.defaultPassword || "",
|
||||
passwordConfirm: this.props.defaultPassword || "",
|
||||
passwordComplexity: null,
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_begin");
|
||||
}
|
||||
|
||||
private onSubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
ev.persist();
|
||||
|
||||
if (!this.props.canSubmit) return;
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.email === '') {
|
||||
if (this.showEmail()) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
|
||||
Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, {
|
||||
onFinished: async (confirmed: boolean, email?: string) => {
|
||||
if (confirmed) {
|
||||
this.setState({
|
||||
email,
|
||||
}, () => {
|
||||
this.doSubmit(ev);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// user can't set an e-mail so don't prompt them to
|
||||
this.doSubmit(ev);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.doSubmit(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private doSubmit(ev) {
|
||||
const email = this.state.email.trim();
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
|
||||
email: !!email,
|
||||
});
|
||||
|
||||
const promise = this.props.onRegisterClick({
|
||||
username: this.state.username.trim(),
|
||||
password: this.state.password.trim(),
|
||||
email: email,
|
||||
phoneCountry: this.state.phoneCountry,
|
||||
phoneNumber: this.state.phoneNumber,
|
||||
});
|
||||
|
||||
if (promise) {
|
||||
ev.target.disabled = true;
|
||||
promise.finally(function() {
|
||||
ev.target.disabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
RegistrationField.Username,
|
||||
RegistrationField.Password,
|
||||
RegistrationField.PasswordConfirm,
|
||||
RegistrationField.Email,
|
||||
RegistrationField.PhoneNumber,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
// values for required fields.
|
||||
for (const fieldID of fieldIDsInDisplayOrder) {
|
||||
const field = this[fieldID];
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
// We must wait for these validations to finish before queueing
|
||||
// up the setState below so our setState goes in the queue after
|
||||
// all the setStates from these validate calls (that's how we
|
||||
// know they've finished).
|
||||
await field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise<void>(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
|
||||
|
||||
if (!invalidField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus the first invalid field and show feedback in the stricter mode
|
||||
// that no longer allows empty values for required fields.
|
||||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if all fields were valid last time they were validated.
|
||||
*/
|
||||
private allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private findFirstInvalidField(fieldIDs: RegistrationField[]) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private markFieldValid(fieldID: RegistrationField, valid: boolean) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldValid,
|
||||
});
|
||||
}
|
||||
|
||||
private onEmailChange = ev => {
|
||||
this.setState({
|
||||
email: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
private onEmailValidate = async fieldState => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(RegistrationField.Email, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validateEmailRules = withValidation({
|
||||
description: () => _t("Use an email address to recover your account"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test(this: RegistrationForm, { value, allowEmpty }) {
|
||||
return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address (required on this homeserver)"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPasswordChange = ev => {
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
private onPasswordValidate = result => {
|
||||
this.markFieldValid(RegistrationField.Password, result.valid);
|
||||
};
|
||||
|
||||
private onPasswordConfirmChange = ev => {
|
||||
this.setState({
|
||||
passwordConfirm: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
private onPasswordConfirmValidate = async fieldState => {
|
||||
const result = await this.validatePasswordConfirmRules(fieldState);
|
||||
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePasswordConfirmRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Confirm password"),
|
||||
},
|
||||
{
|
||||
key: "match",
|
||||
test(this: RegistrationForm, { value }) {
|
||||
return !value || value === this.state.password;
|
||||
},
|
||||
invalid: () => _t("Passwords don't match"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPhoneCountryChange = newVal => {
|
||||
this.setState({
|
||||
phoneCountry: newVal.iso2,
|
||||
});
|
||||
};
|
||||
|
||||
private onPhoneNumberChange = ev => {
|
||||
this.setState({
|
||||
phoneNumber: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
private onPhoneNumberValidate = async fieldState => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePhoneNumberRules = withValidation({
|
||||
description: () => _t("Other users can invite you to rooms using your contact details"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test(this: RegistrationForm, { value, allowEmpty }) {
|
||||
return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number (required on this homeserver)"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || phoneNumberLooksValid(value),
|
||||
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onUsernameChange = ev => {
|
||||
this.setState({
|
||||
username: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
private onUsernameValidate = async fieldState => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(RegistrationField.Username, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validateUsernameRules = withValidation({
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Enter username"),
|
||||
},
|
||||
{
|
||||
key: "safeLocalpart",
|
||||
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
|
||||
invalid: () => _t("Some characters not allowed"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* A step is required if all flows include that step.
|
||||
*
|
||||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is required
|
||||
*/
|
||||
private authStepIsRequired(step: string) {
|
||||
return this.props.flows.every((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A step is used if any flows include that step.
|
||||
*
|
||||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is used
|
||||
*/
|
||||
private authStepIsUsed(step: string) {
|
||||
return this.props.flows.some((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
}
|
||||
|
||||
private showEmail() {
|
||||
if (!this.authStepIsUsed('m.login.email.identity')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private showPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private renderEmail() {
|
||||
if (!this.showEmail()) {
|
||||
return null;
|
||||
}
|
||||
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
|
||||
_t("Email") :
|
||||
_t("Email (optional)");
|
||||
return <Field
|
||||
ref={field => this[RegistrationField.Email] = field}
|
||||
type="text"
|
||||
label={emailPlaceholder}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}
|
||||
onValidate={this.onEmailValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
private renderPassword() {
|
||||
return <PassphraseField
|
||||
id="mx_RegistrationForm_password"
|
||||
fieldRef={field => this[RegistrationField.Password] = field}
|
||||
minScore={PASSWORD_MIN_SCORE}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChange}
|
||||
onValidate={this.onPasswordValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderPasswordConfirm() {
|
||||
return <Field
|
||||
id="mx_RegistrationForm_passwordConfirm"
|
||||
ref={field => this[RegistrationField.PasswordConfirm] = field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={_t("Confirm password")}
|
||||
value={this.state.passwordConfirm}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderPhoneNumber() {
|
||||
if (!this.showPhoneNumber()) {
|
||||
return null;
|
||||
}
|
||||
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
|
||||
_t("Phone") :
|
||||
_t("Phone (optional)");
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.state.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChange}
|
||||
/>;
|
||||
return <Field
|
||||
ref={field => this[RegistrationField.PhoneNumber] = field}
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
value={this.state.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChange}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderUsername() {
|
||||
return <Field
|
||||
id="mx_RegistrationForm_username"
|
||||
ref={field => this[RegistrationField.Username] = field}
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
label={_t("Username")}
|
||||
placeholder={_t("Username").toLocaleLowerCase()}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChange}
|
||||
onValidate={this.onUsernameValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const registerButton = (
|
||||
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
|
||||
);
|
||||
|
||||
let emailHelperText = null;
|
||||
if (this.showEmail()) {
|
||||
if (this.showPhoneNumber()) {
|
||||
emailHelperText = <div>
|
||||
{
|
||||
_t("Add an email to be able to reset your password.")
|
||||
} {
|
||||
_t("Use email or phone to optionally be discoverable by existing contacts.")
|
||||
}
|
||||
</div>;
|
||||
} else {
|
||||
emailHelperText = <div>
|
||||
{
|
||||
_t("Add an email to be able to reset your password.")
|
||||
} {
|
||||
_t("Use email to optionally be discoverable by existing contacts.")
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderUsername()}
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderPassword()}
|
||||
{this.renderPasswordConfirm()}
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderEmail()}
|
||||
{this.renderPhoneNumber()}
|
||||
</div>
|
||||
{ emailHelperText }
|
||||
{ registerButton }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/*
|
||||
* A pure UI component which displays the HS and IS to use.
|
||||
*/
|
||||
|
||||
export default class ServerConfig extends React.PureComponent {
|
||||
static propTypes = {
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
|
||||
// The current configuration that the user is expecting to change.
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
|
||||
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
|
||||
|
||||
// Called after the component calls onServerConfigChange
|
||||
onAfterSubmit: PropTypes.func,
|
||||
|
||||
// Optional text for the submit button. If falsey, no button will be shown.
|
||||
submitText: PropTypes.string,
|
||||
|
||||
// Optional class for the submit button. Only applies if the submit button
|
||||
// is to be rendered.
|
||||
submitClass: PropTypes.string,
|
||||
|
||||
// Whether the flow this component is embedded in requires an identity
|
||||
// server when the homeserver says it will need one. Default false.
|
||||
showIdentityServerIfRequiredByHomeserver: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onServerConfigChange: function() {},
|
||||
delayTimeMs: 0,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: "",
|
||||
hsUrl: props.serverConfig.hsUrl,
|
||||
isUrl: props.serverConfig.isUrl,
|
||||
showIdentityServer: false,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.state.isUrl) return;
|
||||
|
||||
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
|
||||
}
|
||||
|
||||
async validateServer() {
|
||||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If the UI flow this component is embedded in requires an identity
|
||||
// server when the homeserver says it will need one, check first and
|
||||
// reveal this field if not already shown.
|
||||
// XXX: This a backward compatibility path for homeservers that require
|
||||
// an identity server to be passed during certain flows.
|
||||
// See also https://github.com/matrix-org/synapse/pull/5868.
|
||||
if (
|
||||
this.props.showIdentityServerIfRequiredByHomeserver &&
|
||||
!this.state.showIdentityServer &&
|
||||
await this.isIdentityServerRequiredByHomeserver()
|
||||
) {
|
||||
this.setState({
|
||||
showIdentityServer: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async validateAndApplyServer(hsUrl, isUrl) {
|
||||
// Always try and use the defaults first
|
||||
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
|
||||
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
|
||||
this.setState({
|
||||
hsUrl: defaultConfig.hsUrl,
|
||||
isUrl: defaultConfig.isUrl,
|
||||
busy: false,
|
||||
errorText: "",
|
||||
});
|
||||
this.props.onServerConfigChange(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
hsUrl,
|
||||
isUrl,
|
||||
busy: true,
|
||||
errorText: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
if (!stateForError.isFatalError) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
// carry on anyway
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} else {
|
||||
let message = _t("Unable to validate homeserver/identity server");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: message,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async isIdentityServerRequiredByHomeserver() {
|
||||
// XXX: We shouldn't have to create a whole new MatrixClient just to
|
||||
// check if the homeserver requires an identity server... Should it be
|
||||
// extracted to a static utils function...?
|
||||
return createClient({
|
||||
baseUrl: this.state.hsUrl,
|
||||
}).doesServerRequireIdServerParam();
|
||||
}
|
||||
|
||||
onHomeserverBlur = (ev) => {
|
||||
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
|
||||
this.validateServer();
|
||||
});
|
||||
};
|
||||
|
||||
onHomeserverChange = (ev) => {
|
||||
const hsUrl = ev.target.value;
|
||||
this.setState({ hsUrl });
|
||||
};
|
||||
|
||||
onIdentityServerBlur = (ev) => {
|
||||
this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => {
|
||||
this.validateServer();
|
||||
});
|
||||
};
|
||||
|
||||
onIdentityServerChange = (ev) => {
|
||||
const isUrl = ev.target.value;
|
||||
this.setState({ isUrl });
|
||||
};
|
||||
|
||||
onSubmit = async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const result = await this.validateServer();
|
||||
if (!result) return; // Do not continue.
|
||||
|
||||
if (this.props.onAfterSubmit) {
|
||||
this.props.onAfterSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
_waitThenInvoke(existingTimeoutId, fn) {
|
||||
if (existingTimeoutId) {
|
||||
clearTimeout(existingTimeoutId);
|
||||
}
|
||||
return setTimeout(fn.bind(this), this.props.delayTimeMs);
|
||||
}
|
||||
|
||||
showHelpPopup = () => {
|
||||
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
|
||||
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
|
||||
};
|
||||
|
||||
_renderHomeserverSection() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <div>
|
||||
{_t("Enter your custom homeserver URL <a>What does this mean?</a>", {}, {
|
||||
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
|
||||
{sub}
|
||||
</a>,
|
||||
})}
|
||||
<Field
|
||||
id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Homeserver URL")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
_renderIdentityServerSection() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const classes = classNames({
|
||||
"mx_ServerConfig_identityServer": true,
|
||||
"mx_ServerConfig_identityServer_shown": this.state.showIdentityServer,
|
||||
});
|
||||
return <div className={classes}>
|
||||
{_t("Enter your custom identity server URL <a>What does this mean?</a>", {}, {
|
||||
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
|
||||
{sub}
|
||||
</a>,
|
||||
})}
|
||||
<Field
|
||||
label={_t("Identity Server URL")}
|
||||
placeholder={this.props.serverConfig.isUrl}
|
||||
value={this.state.isUrl || ''}
|
||||
onBlur={this.onIdentityServerBlur}
|
||||
onChange={this.onIdentityServerChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const errorText = this.state.errorText
|
||||
? <span className='mx_ServerConfig_error'>{this.state.errorText}</span>
|
||||
: null;
|
||||
|
||||
const submitButton = this.props.submitText
|
||||
? <AccessibleButton
|
||||
element="button"
|
||||
type="submit"
|
||||
className={this.props.submitClass}
|
||||
onClick={this.onSubmit}
|
||||
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form className="mx_ServerConfig" onSubmit={this.onSubmit} autoComplete="off">
|
||||
<h3>{_t("Other servers")}</h3>
|
||||
{errorText}
|
||||
{this._renderHomeserverSection()}
|
||||
{this._renderIdentityServerSection()}
|
||||
{submitButton}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,152 +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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import classnames from 'classnames';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import {makeType} from "../../../utils/TypeUtils";
|
||||
|
||||
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
|
||||
export const FREE = 'Free';
|
||||
export const PREMIUM = 'Premium';
|
||||
export const ADVANCED = 'Advanced';
|
||||
|
||||
export const TYPES = {
|
||||
FREE: {
|
||||
id: FREE,
|
||||
label: () => _t('Free'),
|
||||
logo: () => <img src={require('../../../../res/img/matrix-org-bw-logo.svg')} />,
|
||||
description: () => _t('Join millions for free on the largest public server'),
|
||||
serverConfig: makeType(ValidatedServerConfig, {
|
||||
hsUrl: "https://matrix-client.matrix.org",
|
||||
hsName: "matrix.org",
|
||||
hsNameIsDifferent: false,
|
||||
isUrl: "https://vector.im",
|
||||
}),
|
||||
},
|
||||
PREMIUM: {
|
||||
id: PREMIUM,
|
||||
label: () => _t('Premium'),
|
||||
logo: () => <img src={require('../../../../res/img/modular-bw-logo.svg')} />,
|
||||
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</a>,
|
||||
}),
|
||||
identityServerUrl: "https://vector.im",
|
||||
},
|
||||
ADVANCED: {
|
||||
id: ADVANCED,
|
||||
label: () => _t('Advanced'),
|
||||
logo: () => <div>
|
||||
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
|
||||
{_t('Other')}
|
||||
</div>,
|
||||
description: () => _t('Find other public servers or use a custom server'),
|
||||
},
|
||||
};
|
||||
|
||||
export function getTypeFromServerConfig(config) {
|
||||
const {hsUrl} = config;
|
||||
if (!hsUrl) {
|
||||
return null;
|
||||
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
|
||||
return FREE;
|
||||
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
|
||||
// This is an unlikely case to reach, as Modular defaults to hiding the
|
||||
// server type selector.
|
||||
return PREMIUM;
|
||||
} else {
|
||||
return ADVANCED;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ServerTypeSelector extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The default selected type.
|
||||
selected: PropTypes.string,
|
||||
// Handler called when the selected type changes.
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
selected,
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
updateSelectedType(type) {
|
||||
if (this.state.selected === type) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
selected: type,
|
||||
});
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(type);
|
||||
}
|
||||
}
|
||||
|
||||
onClick = (e) => {
|
||||
e.stopPropagation();
|
||||
const type = e.currentTarget.dataset.id;
|
||||
this.updateSelectedType(type);
|
||||
};
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const serverTypes = [];
|
||||
for (const type of Object.values(TYPES)) {
|
||||
const { id, label, logo, description } = type;
|
||||
const classes = classnames(
|
||||
"mx_ServerTypeSelector_type",
|
||||
`mx_ServerTypeSelector_type_${id}`,
|
||||
{
|
||||
"mx_ServerTypeSelector_type_selected": id === this.state.selected,
|
||||
},
|
||||
);
|
||||
|
||||
serverTypes.push(<div className={classes} key={id} >
|
||||
<div className="mx_ServerTypeSelector_label">
|
||||
{label()}
|
||||
</div>
|
||||
<AccessibleButton onClick={this.onClick} data-id={id}>
|
||||
<div className="mx_ServerTypeSelector_logo">
|
||||
{logo()}
|
||||
</div>
|
||||
<div className="mx_ServerTypeSelector_description">
|
||||
{description()}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return <div className="mx_ServerTypeSelector">
|
||||
{serverTypes}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +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 {_t} from "../../../languageHandler";
|
||||
import * as sdk from "../../../index";
|
||||
import PropTypes from "prop-types";
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
export default class SignInToText extends React.PureComponent {
|
||||
static propTypes = {
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
};
|
||||
|
||||
render() {
|
||||
let signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
signInToText = _t('Sign in to your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
if (this.props.onEditServerDetailsClick) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.props.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
return <h3>
|
||||
{signInToText}
|
||||
{editLink}
|
||||
</h3>;
|
||||
}
|
||||
}
|
||||
@@ -15,17 +15,28 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from "classnames";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import AuthPage from "./AuthPage";
|
||||
import * as Matrix from "matrix-js-sdk";
|
||||
import {_td} from "../../../languageHandler";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import { _td } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
// translatable strings for Welcome pages
|
||||
_td("Sign in with SSO");
|
||||
|
||||
@replaceableComponent("views.auth.Welcome")
|
||||
export default class Welcome extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_welcome");
|
||||
}
|
||||
|
||||
render() {
|
||||
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
|
||||
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');
|
||||
@@ -39,23 +50,17 @@ export default class Welcome extends React.PureComponent {
|
||||
pageUrl = 'welcome.html';
|
||||
}
|
||||
|
||||
const {hsUrl, isUrl} = this.props.serverConfig;
|
||||
const tmpClient = Matrix.createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
});
|
||||
const plaf = PlatformPeg.get();
|
||||
const callbackUrl = plaf.getSSOCallbackUrl(tmpClient.getHomeserverUrl(), tmpClient.getIdentityServerUrl());
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<div className="mx_Welcome">
|
||||
<div className={classNames("mx_Welcome", {
|
||||
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
|
||||
})}>
|
||||
<EmbeddedPage
|
||||
className="mx_WelcomePage"
|
||||
url={pageUrl}
|
||||
replaceMap={{
|
||||
"$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"),
|
||||
"$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"),
|
||||
"$riot:ssoUrl": "#/start_sso",
|
||||
"$riot:casUrl": "#/start_cas",
|
||||
}}
|
||||
/>
|
||||
<LanguageSelector />
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as AvatarLogic from '../../../Avatar';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: '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
|
||||
inputRef: PropTypes.oneOfType([
|
||||
// Either a function
|
||||
PropTypes.func,
|
||||
// Or the instance of a DOM native element
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
},
|
||||
|
||||
statics: {
|
||||
contextType: MatrixClientContext,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
defaultToInitialLetter: true,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return this._getState(this.props);
|
||||
},
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on('sync', this.onClientSync);
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener('sync', this.onClientSync);
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(nextProps) {
|
||||
// work out if we need to call setState (if the image URLs array has changed)
|
||||
const newState = this._getState(nextProps);
|
||||
const newImageUrls = newState.imageUrls;
|
||||
const oldImageUrls = this.state.imageUrls;
|
||||
if (newImageUrls.length !== oldImageUrls.length) {
|
||||
this.setState(newState); // detected a new entry
|
||||
} else {
|
||||
// check each one to see if they are the same
|
||||
for (let i = 0; i < newImageUrls.length; i++) {
|
||||
if (oldImageUrls[i] !== newImageUrls[i]) {
|
||||
this.setState(newState); // detected a diff
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onClientSync: function(syncState, prevState) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
if (reconnected &&
|
||||
// Did we fall back?
|
||||
this.state.urlsIndex > 0
|
||||
) {
|
||||
// Start from the highest priority URL again
|
||||
this.setState({
|
||||
urlsIndex: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_getState: function(props) {
|
||||
// work out the full set of urls to try to load. This is formed like so:
|
||||
// imageUrls: [ props.url, props.urls, default image ]
|
||||
|
||||
let urls = [];
|
||||
if (!SettingsStore.getValue("lowBandwidth")) {
|
||||
urls = props.urls || [];
|
||||
|
||||
if (props.url) {
|
||||
urls.unshift(props.url); // put in urls[0]
|
||||
}
|
||||
}
|
||||
|
||||
let defaultImageUrl = null;
|
||||
if (props.defaultToInitialLetter) {
|
||||
defaultImageUrl = AvatarLogic.defaultAvatarUrlForString(
|
||||
props.idName || props.name,
|
||||
);
|
||||
urls.push(defaultImageUrl); // lowest priority
|
||||
}
|
||||
|
||||
// deduplicate URLs
|
||||
urls = Array.from(new Set(urls));
|
||||
|
||||
return {
|
||||
imageUrls: urls,
|
||||
defaultImageUrl: defaultImageUrl,
|
||||
urlsIndex: 0,
|
||||
};
|
||||
},
|
||||
|
||||
onError: function(ev) {
|
||||
const nextIndex = this.state.urlsIndex + 1;
|
||||
if (nextIndex < this.state.imageUrls.length) {
|
||||
// try the next one
|
||||
this.setState({
|
||||
urlsIndex: nextIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const imageUrl = this.state.imageUrls[this.state.urlsIndex];
|
||||
|
||||
const {
|
||||
name, idName, title, url, urls, width, height, resizeMethod,
|
||||
defaultToInitialLetter, onClick, inputRef,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
if (imageUrl === this.state.defaultImageUrl) {
|
||||
const initialLetter = AvatarLogic.getInitialLetter(name);
|
||||
const textNode = (
|
||||
<span className="mx_BaseAvatar_initial" aria-hidden="true"
|
||||
style={{ fontSize: (width * 0.65) + "px",
|
||||
width: width + "px",
|
||||
lineHeight: height + "px" }}
|
||||
>
|
||||
{ initialLetter }
|
||||
</span>
|
||||
);
|
||||
const imgNode = (
|
||||
<img className="mx_BaseAvatar_image" src={imageUrl}
|
||||
alt="" title={title} onError={this.onError}
|
||||
width={width} height={height} aria-hidden="true" />
|
||||
);
|
||||
if (onClick != null) {
|
||||
return (
|
||||
<AccessibleButton element='span' className="mx_BaseAvatar"
|
||||
onClick={onClick} inputRef={inputRef} {...otherProps}
|
||||
>
|
||||
{ textNode }
|
||||
{ imgNode }
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="mx_BaseAvatar" ref={inputRef} {...otherProps}>
|
||||
{ textNode }
|
||||
{ imgNode }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (onClick != null) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_BaseAvatar mx_BaseAvatar_image"
|
||||
element='img'
|
||||
src={imageUrl}
|
||||
onClick={onClick}
|
||||
onError={this.onError}
|
||||
width={width} height={height}
|
||||
title={title} alt=""
|
||||
inputRef={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<img
|
||||
className="mx_BaseAvatar mx_BaseAvatar_image"
|
||||
src={imageUrl}
|
||||
onError={this.onError}
|
||||
width={width} height={height}
|
||||
title={title} alt=""
|
||||
ref={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
212
src/components/views/avatars/BaseAvatar.tsx
Normal file
212
src/components/views/avatars/BaseAvatar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 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, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
|
||||
|
||||
import * as AvatarLogic from '../../../Avatar';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { toPx } from "../../../utils/units";
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
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?: ResizeMethod;
|
||||
defaultToInitialLetter?: boolean; // true to add default url
|
||||
onClick?: React.MouseEventHandler;
|
||||
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const calculateUrls = (url, urls, lowBandwidth) => {
|
||||
// work out the full set of urls to try to load. This is formed like so:
|
||||
// imageUrls: [ props.url, ...props.urls ]
|
||||
|
||||
let _urls = [];
|
||||
if (!lowBandwidth) {
|
||||
_urls = urls || [];
|
||||
|
||||
if (url) {
|
||||
// copy urls and put url first
|
||||
_urls = [url, ..._urls];
|
||||
}
|
||||
}
|
||||
|
||||
// deduplicate URLs
|
||||
return Array.from(new Set(_urls));
|
||||
};
|
||||
|
||||
const useImageUrl = ({ url, urls }): [string, () => void] => {
|
||||
// Since this is a hot code path and the settings store can be slow, we
|
||||
// use the cached lowBandwidth value from the room context if it exists
|
||||
const roomContext = useContext(RoomContext);
|
||||
const lowBandwidth = roomContext ?
|
||||
roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
|
||||
|
||||
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
|
||||
const [urlsIndex, setIndex] = useState<number>(0);
|
||||
|
||||
const onError = useCallback(() => {
|
||||
setIndex(i => i + 1); // try the next one
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setUrls(calculateUrls(url, urls, lowBandwidth));
|
||||
setIndex(0);
|
||||
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const onClientSync = useCallback((syncState, prevState) => {
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
if (reconnected) {
|
||||
setIndex(0);
|
||||
}
|
||||
}, []);
|
||||
useEventEmitter(cli, "sync", onClientSync);
|
||||
|
||||
const imageUrl = imageUrls[urlsIndex];
|
||||
return [imageUrl, onError];
|
||||
};
|
||||
|
||||
const BaseAvatar = (props: IProps) => {
|
||||
const {
|
||||
name,
|
||||
idName,
|
||||
title,
|
||||
url,
|
||||
urls,
|
||||
width = 40,
|
||||
height = 40,
|
||||
resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
defaultToInitialLetter = true,
|
||||
onClick,
|
||||
inputRef,
|
||||
className,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const [imageUrl, onError] = useImageUrl({ url, urls });
|
||||
|
||||
if (!imageUrl && defaultToInitialLetter) {
|
||||
const initialLetter = AvatarLogic.getInitialLetter(name);
|
||||
const textNode = (
|
||||
<span
|
||||
className="mx_BaseAvatar_initial"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
fontSize: toPx(width * 0.65),
|
||||
width: toPx(width),
|
||||
lineHeight: toPx(height),
|
||||
}}
|
||||
>
|
||||
{ initialLetter }
|
||||
</span>
|
||||
);
|
||||
const imgNode = (
|
||||
<img
|
||||
className="mx_BaseAvatar_image"
|
||||
src={AvatarLogic.defaultAvatarUrlForString(idName || name)}
|
||||
alt=""
|
||||
title={title}
|
||||
onError={onError}
|
||||
style={{
|
||||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
aria-hidden="true" />
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
aria-label={_t("Avatar")}
|
||||
{...otherProps}
|
||||
element="span"
|
||||
className={classNames("mx_BaseAvatar", className)}
|
||||
onClick={onClick}
|
||||
inputRef={inputRef}
|
||||
>
|
||||
{ textNode }
|
||||
{ imgNode }
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
className={classNames("mx_BaseAvatar", className)}
|
||||
ref={inputRef}
|
||||
{...otherProps}
|
||||
role="presentation"
|
||||
>
|
||||
{ textNode }
|
||||
{ imgNode }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
|
||||
element='img'
|
||||
src={imageUrl}
|
||||
onClick={onClick}
|
||||
onError={onError}
|
||||
style={{
|
||||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt={_t("Avatar")}
|
||||
inputRef={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<img
|
||||
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
|
||||
src={imageUrl}
|
||||
onError={onError}
|
||||
style={{
|
||||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt=""
|
||||
ref={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default BaseAvatar;
|
||||
export type BaseAvatarType = React.FC<IProps>;
|
||||
212
src/components/views/avatars/DecoratedRoomAvatar.tsx
Normal file
212
src/components/views/avatars/DecoratedRoomAvatar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import RoomAvatar from "./RoomAvatar";
|
||||
import NotificationBadge from '../rooms/NotificationBadge';
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
import { isPresenceEnabled } from "../../../utils/presence";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { IOOBData } from "../../../stores/ThreepidInviteStore";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
avatarSize: number;
|
||||
displayBadge?: boolean;
|
||||
forceCount?: boolean;
|
||||
oobData?: IOOBData;
|
||||
viewAvatarOnClick?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
notificationState?: NotificationState;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
enum Icon {
|
||||
// Note: the names here are used in CSS class names
|
||||
None = "NONE", // ... except this one
|
||||
Globe = "GLOBE",
|
||||
PresenceOnline = "ONLINE",
|
||||
PresenceAway = "AWAY",
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@replaceableComponent("views.avatars.DecoratedRoomAvatar")
|
||||
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
|
||||
private _dmUser: User;
|
||||
private isUnmounted = false;
|
||||
private isWatchingTimeline = false;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room),
|
||||
icon: this.calculateIcon(),
|
||||
};
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.isUnmounted = true;
|
||||
if (this.isWatchingTimeline) this.props.room.off('Room.timeline', this.onRoomTimeline);
|
||||
this.dmUser = null; // clear listeners, if any
|
||||
}
|
||||
|
||||
private get isPublicRoom(): boolean {
|
||||
const joinRules = this.props.room.currentState.getStateEvents("m.room.join_rules", "");
|
||||
const joinRule = joinRules && joinRules.getContent().join_rule;
|
||||
return joinRule === 'public';
|
||||
}
|
||||
|
||||
private get dmUser(): User {
|
||||
return this._dmUser;
|
||||
}
|
||||
|
||||
private set dmUser(val: User) {
|
||||
const oldUser = this._dmUser;
|
||||
this._dmUser = val;
|
||||
if (oldUser && oldUser !== this._dmUser) {
|
||||
oldUser.off('User.currentlyActive', this.onPresenceUpdate);
|
||||
oldUser.off('User.presence', this.onPresenceUpdate);
|
||||
}
|
||||
if (this._dmUser && oldUser !== this._dmUser) {
|
||||
this._dmUser.on('User.currentlyActive', this.onPresenceUpdate);
|
||||
this._dmUser.on('User.presence', this.onPresenceUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private onRoomTimeline = (ev: MatrixEvent, room: Room) => {
|
||||
if (this.isUnmounted) return;
|
||||
|
||||
// apparently these can happen?
|
||||
if (!room) return;
|
||||
if (this.props.room.roomId !== room.roomId) return;
|
||||
|
||||
if (ev.getType() === 'm.room.join_rules' || ev.getType() === 'm.room.member') {
|
||||
const newIcon = this.calculateIcon();
|
||||
if (newIcon !== this.state.icon) {
|
||||
this.setState({ icon: newIcon });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onPresenceUpdate = () => {
|
||||
if (this.isUnmounted) return;
|
||||
|
||||
const newIcon = this.getPresenceIcon();
|
||||
if (newIcon !== this.state.icon) this.setState({ icon: newIcon });
|
||||
};
|
||||
|
||||
private getPresenceIcon(): Icon {
|
||||
if (!this.dmUser) return Icon.None;
|
||||
|
||||
let icon = Icon.None;
|
||||
|
||||
const isOnline = this.dmUser.currentlyActive || this.dmUser.presence === 'online';
|
||||
if (isOnline) {
|
||||
icon = Icon.PresenceOnline;
|
||||
} else if (this.dmUser.presence === 'offline') {
|
||||
icon = Icon.PresenceOffline;
|
||||
} else if (this.dmUser.presence === 'unavailable') {
|
||||
icon = Icon.PresenceAway;
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
private calculateIcon(): Icon {
|
||||
let icon = Icon.None;
|
||||
|
||||
// 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()) {
|
||||
if (otherUserId) {
|
||||
this.dmUser = MatrixClientPeg.get().getUser(otherUserId);
|
||||
icon = this.getPresenceIcon();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Track publicity
|
||||
icon = this.isPublicRoom ? Icon.Globe : Icon.None;
|
||||
if (!this.isWatchingTimeline) {
|
||||
this.props.room.on('Room.timeline', this.onRoomTimeline);
|
||||
this.isWatchingTimeline = true;
|
||||
}
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
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}
|
||||
/>;
|
||||
}
|
||||
|
||||
let icon;
|
||||
if (this.state.icon !== Icon.None) {
|
||||
icon = <TextWithTooltip
|
||||
tooltip={tooltipText(this.state.icon)}
|
||||
class={`mx_DecoratedRoomAvatar_icon mx_DecoratedRoomAvatar_icon_${this.state.icon.toLowerCase()}`}
|
||||
/>;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_DecoratedRoomAvatar", {
|
||||
mx_DecoratedRoomAvatar_cutout: icon,
|
||||
});
|
||||
|
||||
return <div className={classes}>
|
||||
<RoomAvatar
|
||||
room={this.props.room}
|
||||
width={this.props.avatarSize}
|
||||
height={this.props.avatarSize}
|
||||
oobData={this.props.oobData}
|
||||
viewAvatarOnClick={this.props.viewAvatarOnClick}
|
||||
/>
|
||||
{icon}
|
||||
{badge}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +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 PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'GroupAvatar',
|
||||
|
||||
propTypes: {
|
||||
groupId: PropTypes.string,
|
||||
groupName: PropTypes.string,
|
||||
groupAvatarUrl: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 36,
|
||||
height: 36,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
},
|
||||
|
||||
getGroupAvatarUrl: function() {
|
||||
return MatrixClientPeg.get().mxcUrlToHttp(
|
||||
this.props.groupAvatarUrl,
|
||||
this.props.width,
|
||||
this.props.height,
|
||||
this.props.resizeMethod,
|
||||
);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
// 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 }]*/
|
||||
const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props;
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
name={groupName || this.props.groupId[1]}
|
||||
idName={this.props.groupId}
|
||||
url={this.getGroupAvatarUrl()}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
66
src/components/views/avatars/GroupAvatar.tsx
Normal file
66
src/components/views/avatars/GroupAvatar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright 2017, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
|
||||
|
||||
import BaseAvatar from './BaseAvatar';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
|
||||
export interface IProps {
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
groupAvatarUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeMethod?: ResizeMethod;
|
||||
onClick?: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.avatars.GroupAvatar")
|
||||
export default class GroupAvatar extends React.Component<IProps> {
|
||||
public static defaultProps = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
|
||||
getGroupAvatarUrl() {
|
||||
if (!this.props.groupAvatarUrl) return null;
|
||||
return mediaFromMxc(this.props.groupAvatarUrl).getThumbnailOfSourceHttp(
|
||||
this.props.width,
|
||||
this.props.height,
|
||||
this.props.resizeMethod,
|
||||
);
|
||||
}
|
||||
|
||||
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 @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { groupId, groupAvatarUrl, groupName, ...otherProps } = this.props;
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
name={groupName || this.props.groupId[1]}
|
||||
idName={this.props.groupId}
|
||||
url={this.getGroupAvatarUrl()}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +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 * as Avatar from '../../../Avatar';
|
||||
import * as sdk from "../../../index";
|
||||
import dis from "../../../dispatcher";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MemberAvatar',
|
||||
|
||||
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 'view_user'
|
||||
viewUserOnClick: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
viewUserOnClick: false,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return this._getState(this.props);
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(nextProps) {
|
||||
this.setState(this._getState(nextProps));
|
||||
},
|
||||
|
||||
_getState: function(props) {
|
||||
if (props.member && props.member.name) {
|
||||
return {
|
||||
name: props.member.name,
|
||||
title: props.title || props.member.userId,
|
||||
imageUrl: Avatar.avatarUrlForMember(props.member,
|
||||
props.width,
|
||||
props.height,
|
||||
props.resizeMethod),
|
||||
};
|
||||
} else if (props.fallbackUserId) {
|
||||
return {
|
||||
name: props.fallbackUserId,
|
||||
title: props.fallbackUserId,
|
||||
};
|
||||
} else {
|
||||
console.error("MemberAvatar called somehow with null member or fallbackUserId");
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
|
||||
let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props;
|
||||
const userId = member ? member.userId : fallbackUserId;
|
||||
|
||||
if (viewUserOnClick) {
|
||||
onClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_user',
|
||||
member: this.props.member,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
|
||||
idName={userId} url={this.state.imageUrl} onClick={onClick} />
|
||||
);
|
||||
},
|
||||
});
|
||||
109
src/components/views/avatars/MemberAvatar.tsx
Normal file
109
src/components/views/avatars/MemberAvatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
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 { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import BaseAvatar from "./BaseAvatar";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
|
||||
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
|
||||
member: RoomMember;
|
||||
fallbackUserId?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
resizeMethod?: ResizeMethod;
|
||||
// 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;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
name: string;
|
||||
title: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.avatars.MemberAvatar")
|
||||
export default class MemberAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
viewUserOnClick: false,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = MemberAvatar.getState(props);
|
||||
}
|
||||
|
||||
public static getDerivedStateFromProps(nextProps: IProps): IState {
|
||||
return MemberAvatar.getState(nextProps);
|
||||
}
|
||||
|
||||
private static getState(props: IProps): IState {
|
||||
if (props.member?.name) {
|
||||
let imageUrl = null;
|
||||
if (props.member.getMxcAvatarUrl()) {
|
||||
imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
|
||||
props.width,
|
||||
props.height,
|
||||
props.resizeMethod,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: props.member.name,
|
||||
title: props.title || props.member.userId,
|
||||
imageUrl: imageUrl,
|
||||
};
|
||||
} else if (props.fallbackUserId) {
|
||||
return {
|
||||
name: props.fallbackUserId,
|
||||
title: props.fallbackUserId,
|
||||
};
|
||||
} else {
|
||||
console.error("MemberAvatar called somehow with null member or fallbackUserId");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let { member, fallbackUserId, onClick, viewUserOnClick, ...otherProps } = this.props;
|
||||
const userId = member ? member.userId : fallbackUserId;
|
||||
|
||||
if (viewUserOnClick) {
|
||||
onClick = () => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.props.member,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
|
||||
idName={userId} url={this.state.imageUrl} onClick={onClick} />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import classNames from 'classnames';
|
||||
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.avatars.MemberStatusMessageAvatar")
|
||||
export default class MemberStatusMessageAvatar extends React.Component {
|
||||
static propTypes = {
|
||||
member: PropTypes.object.isRequired,
|
||||
@@ -53,7 +55,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
||||
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
|
||||
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
|
||||
}
|
||||
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
|
||||
if (!SettingsStore.getValue("feature_custom_status")) {
|
||||
return;
|
||||
}
|
||||
const { user } = this.props.member;
|
||||
@@ -105,7 +107,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
||||
resizeMethod={this.props.resizeMethod}
|
||||
/>;
|
||||
|
||||
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
|
||||
if (!SettingsStore.getValue("feature_custom_status")) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user