Merge branch 'develop' of https://github.com/matrix-org/matrix-react-sdk into resizable-appsdrawer

 Conflicts:
	res/css/views/rooms/_AppsDrawer.scss
This commit is contained in:
Michael Telatynski 2020-08-20 10:26:35 +01:00
commit ae65ed5c2e
1200 changed files with 50388 additions and 28790 deletions

View file

@ -38,12 +38,13 @@ export default class AutoHideScrollbar extends React.Component {
render() {
return (<div
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
>
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,6 +20,7 @@ import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig';
export default createReactClass({
displayName: 'CompatibilityPage',
@ -38,14 +39,25 @@ export default createReactClass({
},
render: function() {
const brand = SdkConfig.get().brand;
return (
<div className="mx_CompatibilityPage">
<div className="mx_CompatibilityPage_box">
<p>{ _t("Sorry, your browser is <b>not</b> able to run Riot.", {}, { 'b': (sub) => <b>{sub}</b> }) } </p>
<p>{_t(
"Sorry, your browser is <b>not</b> able to run %(brand)s.",
{
brand,
},
{
'b': (sub) => <b>{sub}</b>,
})
}</p>
<p>
{ _t(
"Riot uses many advanced browser features, some of which are not available " +
"%(brand)s uses many advanced browser features, some of which are not available " +
"or experimental in your current browser.",
{ brand },
) }
</p>
<p>

View file

@ -16,13 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useRef, useState} from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import React, {CSSProperties, useRef, useState} from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import {Key} from "../../Keyboard";
import * as sdk from "../../index";
import AccessibleButton from "../views/elements/AccessibleButton";
import {Writeable} from "../../@types/common";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -30,8 +29,8 @@ import AccessibleButton from "../views/elements/AccessibleButton";
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
function getOrCreateContainer() {
let container = document.getElementById(ContextualMenuContainerId);
function getOrCreateContainer(): HTMLDivElement {
let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
if (!container) {
container = document.createElement("div");
@ -43,50 +42,70 @@ function getOrCreateContainer() {
}
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
interface IPosition {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
export enum ChevronFace {
Top = "top",
Bottom = "bottom",
Left = "left",
Right = "right",
None = "none",
}
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;
// Function to be called on menu close
onFinished();
// on resize callback
windowResize?();
}
interface IState {
contextMenuElem: HTMLDivElement;
}
// Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
export class ContextMenu extends React.Component {
static propTypes = {
top: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number,
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func.isRequired,
menuPaddingTop: PropTypes.number,
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
zIndex: PropTypes.number,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
hasBackground: PropTypes.bool,
// on resize callback
windowResize: PropTypes.func,
managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
};
export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement;
static defaultProps = {
hasBackground: true,
managed: true,
};
constructor() {
super();
constructor(props, context) {
super(props, context);
this.state = {
contextMenuElem: null,
};
// persist what had focus when we got initialized so we can return it after
this.initialFocus = document.activeElement;
this.initialFocus = document.activeElement as HTMLElement;
}
componentWillUnmount() {
@ -94,7 +113,7 @@ export class ContextMenu extends React.Component {
this.initialFocus.focus();
}
collectContextMenuRect = (element) => {
private collectContextMenuRect = (element) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
@ -111,11 +130,12 @@ export class ContextMenu extends React.Component {
});
};
onContextMenu = (e) => {
private onContextMenu = (e) => {
if (this.props.onFinished) {
this.props.onFinished();
e.preventDefault();
e.stopPropagation();
const x = e.clientX;
const y = e.clientY;
@ -133,7 +153,20 @@ export class ContextMenu extends React.Component {
}
};
_onMoveFocus = (element, up) => {
private onContextMenuPreventBubbling = (e) => {
// stop propagation so that any context menu handlers don't leak out of this context menu
// but do not inhibit the default browser menu
e.stopPropagation();
};
// Prevent clicks on the background from going through to the component which opened the menu.
private onFinished = (ev: React.MouseEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (this.props.onFinished) this.props.onFinished();
};
private onMoveFocus = (element: Element, up: boolean) => {
let descending = false; // are we currently descending or ascending through the DOM tree?
do {
@ -167,25 +200,25 @@ export class ContextMenu extends React.Component {
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
if (element) {
element.focus();
(element as HTMLElement).focus();
}
};
_onMoveFocusHomeEnd = (element, up) => {
private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
let results = element.querySelectorAll('[role^="menuitem"]');
if (!results) {
results = element.querySelectorAll('[tab-index]');
}
if (results && results.length) {
if (up) {
results[0].focus();
(results[0] as HTMLElement).focus();
} else {
results[results.length - 1].focus();
(results[results.length - 1] as HTMLElement).focus();
}
}
};
_onKeyDown = (ev) => {
private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.props.managed) {
if (ev.key === Key.ESCAPE) {
this.props.onFinished();
@ -200,19 +233,22 @@ export class ContextMenu extends React.Component {
switch (ev.key) {
case Key.TAB:
case Key.ESCAPE:
// close on left and right arrows too for when it is a context menu on a <Toolbar />
case Key.ARROW_LEFT:
case Key.ARROW_RIGHT:
this.props.onFinished();
break;
case Key.ARROW_UP:
this._onMoveFocus(ev.target, true);
this.onMoveFocus(ev.target as Element, true);
break;
case Key.ARROW_DOWN:
this._onMoveFocus(ev.target, false);
this.onMoveFocus(ev.target as Element, false);
break;
case Key.HOME:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
break;
case Key.END:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
break;
default:
handled = false;
@ -225,9 +261,8 @@ export class ContextMenu extends React.Component {
}
};
renderMenu(hasBackground=this.props.hasBackground) {
const position = {};
let chevronFace = null;
protected renderMenu(hasBackground = this.props.hasBackground) {
const position: Partial<Writeable<DOMRect>> = {};
const props = this.props;
if (props.top) {
@ -236,23 +271,24 @@ export class ContextMenu extends React.Component {
position.bottom = props.bottom;
}
let chevronFace: ChevronFace;
if (props.left) {
position.left = props.left;
chevronFace = 'left';
chevronFace = ChevronFace.Left;
} else {
position.right = props.right;
chevronFace = 'right';
chevronFace = ChevronFace.Right;
}
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const chevronOffset = {};
const chevronOffset: CSSProperties = {};
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
const hasChevron = chevronFace && chevronFace !== "none";
const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
if (chevronFace === 'top' || chevronFace === 'bottom') {
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
chevronOffset.left = props.chevronOffset;
} else if (position.top !== undefined) {
const target = position.top;
@ -282,13 +318,13 @@ export class ContextMenu extends React.Component {
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
});
const menuStyle = {};
const menuStyle: CSSProperties = {};
if (props.menuWidth) {
menuStyle.width = props.menuWidth;
}
@ -319,13 +355,28 @@ export class ContextMenu extends React.Component {
let background;
if (hasBackground) {
background = (
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={props.onFinished} onContextMenu={this.onContextMenu} />
<div
className="mx_ContextualMenu_background"
style={wrapperStyle}
onClick={this.onFinished}
onContextMenu={this.onContextMenu}
/>
);
}
return (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
<div
className="mx_ContextualMenu_wrapper"
style={{...position, ...wrapperStyle}}
onKeyDown={this.onKeyDown}
onContextMenu={this.onContextMenuPreventBubbling}
>
<div
className={menuClasses}
style={menuStyle}
ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined}
>
{ chevron }
{ props.children }
</div>
@ -334,91 +385,13 @@ export class ContextMenu extends React.Component {
);
}
render() {
render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} title={label} aria-label={label} aria-haspopup={true} aria-expanded={isExpanded}>
{ children }
</AccessibleButton>
);
};
ContextMenuButton.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
};
// Semantic component for representing a role=menuitem
export const MenuItem = ({children, label, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItem.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};
MenuGroup.propTypes = {
label: PropTypes.string.isRequired,
className: PropTypes.string, // optional
};
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemCheckbox.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemRadio.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect, chevronOffset=12) => {
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
@ -426,8 +399,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect, chevronFace="none") => {
const menuOptions = { chevronFace };
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
@ -485,3 +458,13 @@ export function createMenu(ElementClass, props) {
return {close: onFinished};
}
// re-export the semantic helper components for simplicity
export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
export {ContextMenuTooltipButton} from "../../accessibility/context_menu/ContextMenuTooltipButton";
export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
export {MenuItem} from "../../accessibility/context_menu/MenuItem";
export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";

View file

@ -18,7 +18,7 @@ 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';
@ -72,17 +72,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 (

View file

@ -23,7 +23,7 @@ import PropTypes from 'prop-types';
import request from 'browser-request';
import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import classnames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";

View file

@ -210,6 +210,11 @@ const FilePanel = createReactClass({
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>);
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);
@ -223,7 +228,7 @@ const FilePanel = createReactClass({
onPaginationRequest={this.onPaginationRequest}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}
empty={emptyState}
/>
</div>
);

View file

@ -21,7 +21,7 @@ import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
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';
@ -92,7 +92,7 @@ const CategoryRoomList = createReactClass({
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'],
@ -1003,9 +1003,10 @@ export default createReactClass({
this.state.inviterProfile.avatarUrl, 36, 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 +1017,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">

View file

@ -21,10 +21,11 @@ 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";
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
const onClickExplore = () => dis.dispatch({action: 'view_room_directory'});
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
const HomePage = () => {
@ -37,7 +38,7 @@ const HomePage = () => {
}
const brandingConfig = config.branding;
let logoUrl = "themes/riot/img/logos/riot-logo.svg";
let logoUrl = "themes/element/img/logos/element-logo.svg";
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
logoUrl = brandingConfig.authHeaderLogoUrl;
}
@ -45,8 +46,8 @@ const HomePage = () => {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
<div className="mx_HomePage_default_wrapper">
<img src={logoUrl} alt="Riot" />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Riot" }) }</h1>
<img src={logoUrl} alt={config.brand || "Element"} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
<div className="mx_HomePage_default_buttons">
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">

View file

@ -158,7 +158,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;
}
@ -192,7 +192,7 @@ export default class IndicatorScrollbar extends React.Component {
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
onWheel={this.onMouseWheel}
{... this.props}
{...this.props}
>
{ leftOverflowIndicator }
{ this.props.children }

View file

@ -203,7 +203,7 @@ 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() {

View file

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

View file

@ -0,0 +1,430 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { createRef } from "react";
import TagPanel from "./TagPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
interface IProps {
isMinimized: boolean;
resizeNotifier: ResizeNotifier;
}
interface IState {
showBreadcrumbs: boolean;
showTagPanel: boolean;
}
// List of CSS classes which should be included in keyboard navigation within the room list
const cssClasses = [
"mx_RoomSearch_input",
"mx_RoomSearch_icon", // minimized <RoomSearch />
"mx_RoomSublist_headerText",
"mx_RoomTile",
"mx_RoomSublist_showNButton",
];
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string;
private bgImageWatcherRef: string;
private focusedElement = null;
private isDoingStickyHeaders = false;
constructor(props: IProps) {
super(props);
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
});
// We watch the middle panel because we don't actually get resized, the middle panel does.
// We listen to the noisy channel to avoid choppy reaction times.
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
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);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
}
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
};
private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible;
if (newVal !== this.state.showBreadcrumbs) {
this.setState({showBreadcrumbs: newVal});
// Update the sticky headers too as the breadcrumbs will be popping in or out.
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
}
};
private 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 = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize);
}
const avatarUrlProp = `url(${avatarUrl})`;
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");
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<HTMLDivElement, {
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}>();
let lastTopHeader;
let firstBottomHeader;
for (const sublist of sublists) {
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
header.style.removeProperty("display"); // always clear display:none first
// When an element is <=40% off screen, make it take over
const offScreenFactor = 0.4;
const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
if (isOffTop || sublist === sublists[0]) {
targetStyles.set(header, { stickyTop: true });
if (lastTopHeader) {
lastTopHeader.style.display = "none";
targetStyles.set(lastTopHeader, { makeInvisible: true });
}
lastTopHeader = header;
} else if (isOffBottom && !firstBottomHeader) {
targetStyles.set(header, { stickyBottom: true });
firstBottomHeader = header;
} else {
targetStyles.set(header, {}); // nothing == clear
}
}
// Run over the style changes and make them reality. We check to see if we're about to
// cause a no-op update, as adding/removing properties that are/aren't there cause
// layout updates.
for (const header of targetStyles.keys()) {
const style = targetStyles.get(header);
if (style.makeInvisible) {
// we will have already removed the 'display: none', so add it back.
header.style.display = "none";
continue; // nothing else to do, even if sticky somehow
}
if (style.stickyTop) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyTop");
}
const newTop = `${list.parentElement.offsetTop}px`;
if (header.style.top !== newTop) {
header.style.top = newTop;
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyTop");
}
if (header.style.top) {
header.style.removeProperty('top');
}
}
if (style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
}
}
if (style.stickyTop || style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.add("mx_RoomSublist_headerContainer_sticky");
}
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
} else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
}
if (header.style.width) {
header.style.removeProperty('width');
}
}
}
// add appropriate sticky classes to wrapper so it has
// the necessary top/bottom padding to put the sticky header in
const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper
if (lastTopHeader) {
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop");
} else {
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop");
}
if (firstBottomHeader) {
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom");
} else {
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom");
}
}
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list);
};
private onResize = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
private onFocus = (ev: React.FocusEvent) => {
this.focusedElement = ev.target;
};
private onBlur = () => {
this.focusedElement = null;
};
private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
ev.stopPropagation();
ev.preventDefault();
this.onMoveFocus(ev.key === Key.ARROW_UP);
break;
}
};
private onEnter = () => {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile");
if (firstRoom) {
firstRoom.click();
return true; // to get the field to clear
}
};
private onMoveFocus = (up: boolean) => {
let element = this.focusedElement;
let descending = false; // are we currently descending or ascending through the DOM tree?
let classes: DOMTokenList;
do {
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
} else if (sibling) {
element = sibling;
} else {
descending = false;
element = element.parentElement;
}
} else {
if (sibling) {
element = sibling;
descending = true;
} else {
element = element.parentElement;
}
}
if (element) {
classes = element.classList;
}
} while (element && !cssClasses.some(c => classes.contains(c)));
if (element) {
element.focus();
this.focusedElement = element;
}
};
private renderHeader(): React.ReactNode {
return (
<div className="mx_LeftPanel_userHeader">
<UserMenu isMinimized={this.props.isMinimized} />
</div>
);
}
private renderBreadcrumbs(): React.ReactNode {
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
return (
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>
);
}
}
private renderSearchExplore(): React.ReactNode {
return (
<div
className="mx_LeftPanel_filterContainer"
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
>
<RoomSearch
isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown}
onEnter={this.onEnter}
/>
<AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/>
</div>
);
}
public render(): React.ReactNode {
const tagPanel = !this.state.showTagPanel ? null : (
<div className="mx_LeftPanel_tagPanelContainer">
<TagPanel/>
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
</div>
);
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={null}
collapsed={false}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
onResize={this.onResize}
/>;
const containerClasses = classNames({
"mx_LeftPanel": true,
"mx_LeftPanel_hasTagPanel": !!tagPanel,
"mx_LeftPanel_minimized": this.props.isMinimized,
});
const roomListClasses = classNames(
"mx_LeftPanel_actualRoomListContainer",
"mx_AutoHideScrollbar",
);
return (
<div className={containerClasses}>
{tagPanel}
<aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
{this.renderBreadcrumbs()}
<RoomListNumResults />
<div className="mx_LeftPanel_roomListWrapper">
<div
className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{roomList}
</div>
</div>
</aside>
</div>
);
}
}

View file

@ -19,7 +19,6 @@ limitations under the License.
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
@ -27,11 +26,10 @@ import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import sessionStore from '../../stores/SessionStore';
import {MatrixClientPeg, MatrixClientCreds} from '../../MatrixClientPeg';
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
@ -42,6 +40,23 @@ 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 showSetPasswordToast,
hideToast as hideSetPasswordToast
} from "../../toasts/SetPasswordToast";
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";
// 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.
@ -56,7 +71,7 @@ function canElementReceiveInput(el) {
interface IProps {
matrixClient: MatrixClient;
onRegistered: (credentials: MatrixClientCreds) => Promise<MatrixClient>;
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
viaServers?: string[];
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
@ -64,10 +79,6 @@ interface IProps {
initialEventPixelOffset: number;
leftDisabled: boolean;
rightDisabled: boolean;
showCookieBar: boolean;
hasNewVersion: boolean;
userHasGeneratedPassword: boolean;
showNotifierToolbar: boolean;
page_type: string;
autoJoin: boolean;
thirdPartyInvite?: object;
@ -75,7 +86,6 @@ interface IProps {
currentRoomId: string;
ConferenceHandler?: object;
collapseLhs: boolean;
checkingForUpdate: boolean;
config: {
piwik: {
policyUrl: string;
@ -85,19 +95,26 @@ interface IProps {
currentUserId?: string;
currentGroupId?: string;
currentGroupIsNew?: boolean;
version?: string;
newVersion?: string;
newVersionReleaseNotes?: string;
}
interface IUsageLimit {
limit_type: "monthly_active_user" | string;
admin_contact?: string;
}
interface IState {
mouseDown?: {
x: number;
y: number;
};
syncErrorData: any;
syncErrorData?: {
error: {
data: IUsageLimit;
errcode: string;
};
};
usageLimitEventContent?: IUsageLimit;
useCompactLayout: boolean;
serverNoticeEvents: MatrixEvent[];
userHasGeneratedPassword: boolean;
}
/**
@ -109,7 +126,7 @@ interface IState {
*
* Components mounted below us can access the matrix client via the react context.
*/
class LoggedInView extends React.PureComponent<IProps, IState> {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
static propTypes = {
@ -132,6 +149,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _sessionStore: sessionStore;
protected readonly _sessionStoreToken: { remove: () => void };
protected readonly _compactLayoutWatcherRef: string;
protected resizer: Resizer;
constructor(props, context) {
@ -140,11 +158,8 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
this.state = {
mouseDown: undefined,
syncErrorData: undefined,
userHasGeneratedPassword: false,
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
// any currently active server notice events
serverNoticeEvents: [],
};
// stash the MatrixClient in case we log out before we are unmounted
@ -166,6 +181,10 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
fixupColorFonts();
this._roomView = React.createRef();
@ -178,23 +197,12 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
this._loadResizerPreferences();
}
componentDidUpdate(prevProps, prevState) {
// attempt to guess when a banner was opened or closed
if (
(prevProps.showCookieBar !== this.props.showCookieBar) ||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
(prevState.userHasGeneratedPassword !== this.state.userHasGeneratedPassword) ||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
) {
this.props.resizeNotifier.notifyBannersChanged();
}
}
componentWillUnmount() {
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);
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
@ -219,9 +227,11 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
};
_setStateFromSessionStore = () => {
this.setState({
userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()),
});
if (this._sessionStore.getCachedPassword()) {
showSetPasswordToast();
} else {
hideSetPasswordToast();
}
};
_createResizer() {
@ -262,16 +272,17 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
}
onAccountData = (event) => {
if (event.getType() === "im.vector.web.settings") {
this.setState({
useCompactLayout: event.getContent().useCompactLayout,
});
}
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({action: "ignore_state_changed"});
}
};
onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => {
this.setState({
useCompactLayout: valueAtLevel,
});
};
onSync = (syncState, oldSyncState, data) => {
const oldErrCode = (
this.state.syncErrorData &&
@ -293,22 +304,37 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
this._updateServerNoticeEvents();
} else {
this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
}
};
onRoomStateEvents = (ev, state) => {
const roomLists = RoomListStore.getRoomLists();
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
}
};
_updateServerNoticeEvents = async () => {
const roomLists = RoomListStore.getRoomLists();
if (!roomLists['m.server_notice']) return [];
_calculateServerLimitToast(syncErrorData: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
const error = syncErrorData && syncErrorData.error && syncErrorData.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
if (error) {
usageLimitEventContent = syncErrorData.error.data;
}
const pinnedEvents = [];
for (const room of roomLists['m.server_notice']) {
if (usageLimitEventContent) {
showServerLimitToast(usageLimitEventContent.limit_type, usageLimitEventContent.admin_contact, error);
} else {
hideServerLimitToast();
}
}
_updateServerNoticeEvents = async () => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (!serverNoticeList) return [];
const events = [];
for (const room of serverNoticeList) {
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
@ -317,12 +343,19 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
if (event) pinnedEvents.push(event);
if (event) events.push(event);
}
}
this.setState({
serverNoticeEvents: pinnedEvents,
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 });
};
_onPaste = (ev) => {
@ -337,7 +370,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
// 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);
dis.fire(Action.FocusComposer, true);
}
};
@ -379,20 +412,6 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
};
_onKeyDown = (ev) => {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
// Will need to find a better meta key if anyone actually cares about using this.
if (ev.altKey && ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) {
dis.dispatch({
action: 'view_indexed_room',
roomIndex: ev.keyCode - 49,
});
ev.stopPropagation();
ev.preventDefault();
return;
}
*/
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
@ -429,9 +448,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
// composer, so CTRL+` it is
if (ctrlCmdOnly) {
dis.dispatch({
action: 'toggle_top_left_menu',
});
dis.fire(Action.ToggleUserMenu);
handled = true;
}
break;
@ -446,8 +463,8 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
dis.dispatch({
action: 'view_room_delta',
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: ev.key === Key.ARROW_UP ? -1 : 1,
unread: ev.shiftKey,
});
@ -457,8 +474,8 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
case Key.PERIOD:
if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) {
dis.dispatch({
action: 'toggle_right_panel',
dis.dispatch<ToggleRightPanelPayload>({
action: Action.ToggleRightPanel,
type: this.props.page_type === "room_view" ? "room" : "group",
});
handled = true;
@ -487,7 +504,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input
dis.dispatch({action: 'focus_composer'}, true);
dis.fire(Action.FocusComposer, true);
ev.stopPropagation();
// we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer
@ -579,7 +596,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
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
// combat against https://github.com/vector-im/element-web/issues/7158
if (distance < maxRadius) {
// This is probably a real click, and not a drag
@ -592,18 +609,11 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
};
render() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
const GroupView = sdk.getComponent('structures.GroupView');
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;
@ -647,50 +657,18 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
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';
}
const leftPanel = (
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
);
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
@ -701,20 +679,17 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
onMouseDown={this._onMouseDown}
onMouseUp={this._onMouseUp}
>
{ topBar }
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}>
<LeftPanel
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
{ leftPanel }
<ResizeHandle />
{ pageElement }
</div>
</DragDropContext>
</div>
<CallContainer />
<NonUrgentToastContainer />
</MatrixClientContext.Provider>
);
}

View file

@ -16,77 +16,24 @@ limitations under the License.
*/
import React from 'react';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, FixedDistributor} from '../../resizer';
import { Resizable } from 're-resizable';
export default class MainSplit extends React.Component {
constructor(props) {
super(props);
this._setResizeContainerRef = this._setResizeContainerRef.bind(this);
this._onResized = this._onResized.bind(this);
_onResized = (event, direction, refToElement, delta) => {
window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width);
}
_onResized(size) {
window.localStorage.setItem("mx_rhs_size", size);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.notifyRightHandleResized();
}
}
_loadSidePanelSize() {
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
_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 {
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 +44,29 @@ 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,
}}
onResizeStop={this._onResized}
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>;

View file

@ -17,12 +17,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import {InvalidStoreError} from "matrix-js-sdk/src/errors";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import { isCryptoAvailable } from 'matrix-js-sdk/src/crypto';
import React, { createRef } from 'react';
// @ts-ignore - XXX: no idea why this import fails
import * as Matrix from "matrix-js-sdk";
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible';
// what-input helps improve keyboard accessibility
@ -30,17 +30,17 @@ import 'what-input';
import Analytics from "../../Analytics";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter";
import dis from "../../dispatcher";
import dis from "../../dispatcher/dispatcher";
import Notifier from '../../Notifier';
import Modal from "../../Modal";
import Tinter from "../../Tinter";
import * as sdk from '../../index';
import { showStartChatInviteDialog, showRoomInviteDialog } from '../../RoomInvite';
import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite';
import * as Rooms from '../../Rooms';
import linkifyMatrix from "../../linkify-matrix";
import * as Lifecycle from '../../Lifecycle';
@ -50,23 +50,32 @@ import PageTypes from '../../PageTypes';
import { getHomePageUrl } from '../../utils/pages';
import createRoom from "../../createRoom";
import KeyRequestHandler from '../../KeyRequestHandler';
import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore";
import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
import DMRoomMap from '../../utils/DMRoomMap';
import { countRoomsWithNotif } from '../../RoomNotifs';
import { ThemeWatcher } from "../../theme";
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { FontWatcher } from '../../settings/watchers/FontWatcher';
import { storeRoomAliasInCache } from '../../RoomAliasCache';
import {defer, IDeferred} from "../../utils/promise";
import { defer, IDeferred } from "../../utils/promise";
import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView";
import { ViewUserPayload } from "../../dispatcher/payloads/ViewUserPayload";
import { Action } from "../../dispatcher/actions";
import {
showToast as showAnalyticsToast,
hideToast as hideAnalyticsToast
} from "../../toasts/AnalyticsToast";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { SettingLevel } from "../../settings/SettingLevel";
/** constants for MatrixChat.state.view */
export enum Views {
@ -107,7 +116,7 @@ export enum Views {
// re-dispatched. NOTE: some actions are non-trivial and would require
// re-factoring to be included in this list in future.
const ONBOARDING_FLOW_STARTERS = [
'view_user_settings',
Action.ViewUserSettings,
'view_create_chat',
'view_create_room',
'view_create_group',
@ -145,9 +154,9 @@ interface IProps { // TODO type things better
// Represents the screen to display as a result of parsing the initial window.location
initialScreenAfterLogin?: IScreen;
// displayname, if any, to set on the device when logging in/registering.
defaultDeviceDisplayName?: string,
defaultDeviceDisplayName?: string;
// A function that makes a registration URL
makeRegistrationUrl: (object) => string,
makeRegistrationUrl: (object) => string;
}
interface IState {
@ -168,12 +177,6 @@ interface IState {
leftDisabled: boolean;
middleDisabled: boolean;
// the right panel's disabled state is tracked in its store.
version?: string;
newVersion?: string;
hasNewVersion: boolean;
newVersionReleaseNotes?: string;
checkingForUpdate?: string; // updateCheckStatusEnum
showCookieBar: boolean;
// Parameters used in the registration dance with the IS
register_client_secret?: string;
register_session_id?: string;
@ -183,7 +186,6 @@ interface IState {
hideToSRUsers: boolean;
syncError?: Error;
resizeNotifier: ResizeNotifier;
showNotifierToolbar: boolean;
serverConfig?: ValidatedServerConfig;
ready: boolean;
thirdPartyInvite?: object;
@ -216,6 +218,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private readonly loggedInView: React.RefObject<LoggedInViewType>;
private readonly dispatcherRef: any;
private readonly themeWatcher: ThemeWatcher;
private readonly fontWatcher: FontWatcher;
constructor(props, context) {
super(props, context);
@ -226,17 +229,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
leftDisabled: false,
middleDisabled: false,
hasNewVersion: false,
newVersionReleaseNotes: null,
checkingForUpdate: null,
showCookieBar: false,
hideToSRUsers: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
resizeNotifier: new ResizeNotifier(),
showNotifierToolbar: false,
ready: false,
};
@ -283,8 +279,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.accountPasswordTimer = null;
this.dispatcherRef = dis.register(this.onAction);
this.themeWatcher = new ThemeWatcher();
this.fontWatcher = new FontWatcher();
this.themeWatcher.start();
this.fontWatcher.start();
this.focusComposer = false;
@ -334,12 +333,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
if (SettingsStore.getValue("showCookieBar")) {
this.setState({
showCookieBar: true,
});
}
if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable();
}
@ -358,7 +351,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Analytics.trackPageChange(durationMs);
}
if (this.focusComposer) {
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
this.focusComposer = false;
}
}
@ -367,6 +360,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
this.themeWatcher.stop();
this.fontWatcher.stop();
window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
@ -421,7 +415,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return;
}
this.pageChanging = true;
performance.mark('riot_MatrixChat_page_change_start');
performance.mark('element_MatrixChat_page_change_start');
}
stopPageChangeTimer() {
@ -433,15 +427,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return;
}
this.pageChanging = false;
performance.mark('riot_MatrixChat_page_change_stop');
performance.mark('element_MatrixChat_page_change_stop');
performance.measure(
'riot_MatrixChat_page_change_delta',
'riot_MatrixChat_page_change_start',
'riot_MatrixChat_page_change_stop',
'element_MatrixChat_page_change_delta',
'element_MatrixChat_page_change_start',
'element_MatrixChat_page_change_stop',
);
performance.clearMarks('riot_MatrixChat_page_change_start');
performance.clearMarks('riot_MatrixChat_page_change_stop');
const measurement = performance.getEntriesByName('riot_MatrixChat_page_change_delta').pop();
performance.clearMarks('element_MatrixChat_page_change_start');
performance.clearMarks('element_MatrixChat_page_change_stop');
const measurement = performance.getEntriesByName('element_MatrixChat_page_change_delta').pop();
// In practice, sometimes the entries list is empty, so we get no measurement
if (!measurement) return null;
@ -468,7 +462,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onAction = (payload) => {
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// Start the onboarding process for certain actions
@ -562,6 +555,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'leave_room':
this.leaveRoom(payload.room_id);
break;
case 'forget_room':
this.forgetRoom(payload.room_id);
break;
case 'reject_invite':
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
title: _t('Reject invitation'),
@ -604,33 +600,30 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
}
case 'view_prev_room':
this.viewNextRoom(-1);
break;
case 'view_next_room':
this.viewNextRoom(1);
break;
case 'view_indexed_room':
this.viewIndexedRoom(payload.roomIndex);
break;
case 'view_user_settings': {
case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
{initialTabId: tabPayload.initialTabId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true
);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
break;
}
case 'view_create_room':
this.createRoom();
this.createRoom(payload.public);
break;
case 'view_create_group': {
const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog");
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
break;
}
case 'view_room_directory': {
case Action.ViewRoomDirectory: {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
'mx_RoomDirectory_dialogWrapper', false, true);
@ -680,18 +673,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.dispatch({action: 'view_my_groups'});
}
break;
case 'notifier_enabled':
this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()});
break;
case 'hide_left_panel':
this.setState({
collapseLhs: true,
}, () => {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
case 'focus_room_filter': // for CtrlOrCmd+K to work by expanding the left panel first
case 'show_left_panel':
this.setState({
collapseLhs: false,
}, () => {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
case 'panel_disable': {
@ -730,15 +724,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'client_started':
this.onClientStarted();
break;
case 'new_version':
this.onVersion(
payload.currentVersion, payload.newVersion,
payload.releaseNotes,
);
break;
case 'check_updates':
this.setState({ checkingForUpdate: payload.value });
break;
case 'send_event':
this.onSendEvent(payload.room_id, payload.event);
break;
@ -755,19 +740,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'accept_cookies':
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
this.setState({
showCookieBar: false,
});
hideAnalyticsToast();
Analytics.enable();
break;
case 'reject_cookies':
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
this.setState({
showCookieBar: false,
});
hideAnalyticsToast();
break;
}
};
@ -835,19 +814,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
// TODO: Move to RoomViewStore
private viewIndexedRoom(roomIndex: number) {
const allRooms = RoomListSorter.mostRecentActivityFirst(
MatrixClientPeg.get().getRooms(),
);
if (allRooms[roomIndex]) {
dis.dispatch({
action: 'view_room',
room_id: allRooms[roomIndex].roomId,
});
}
}
// switch view to the given room
//
// @param {Object} roomInfo Object containing data about the room to be joined
@ -926,9 +892,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
private viewGroup(payload) {
private async viewGroup(payload) {
const groupId = payload.group_id;
// Wait for the first sync to complete
if (!this.firstSyncComplete) {
if (!this.firstSyncPromise) {
console.warn('Cannot view a group before first sync. group_id:', groupId);
return;
}
await this.firstSyncPromise.promise;
}
this.setState({
view: Views.LOGGED_IN,
currentGroupId: groupId,
currentGroupIsNew: payload.group_is_new,
});
@ -1012,9 +989,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}).close;
}
private async createRoom() {
private async createRoom(defaultPublic = false) {
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog);
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic });
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
@ -1091,7 +1068,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoom(roomId: string) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId);
@ -1155,6 +1131,21 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
private forgetRoom(roomId: string) {
MatrixClientPeg.get().forget(roomId).then(() => {
// Switch to another room view if we're currently viewing the historical room
if (this.state.currentRoomId === roomId) {
dis.dispatch({ action: "view_next_room" });
}
}).catch((err) => {
const errCode = err.errcode || _td("unknown error code");
Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, {
title: _t("Failed to forget room %(errCode)s", {errCode}),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
/**
* Starts a chat with the welcome user, if the user doesn't already have one
* @returns {string} The room ID of the new room, or null if no room was created
@ -1245,6 +1236,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
StorageManager.tryPersistStorage();
if (SettingsStore.getValue("showCookieBar") && this.props.config.piwik && navigator.doNotTrack !== "1") {
showAnalyticsToast(this.props.config.piwik && this.props.config.piwik.policyUrl);
}
}
private showScreenAfterLogin() {
@ -1328,7 +1323,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// state (each of which can be 10s of MBs) for each DISJOINT timeline. This is
// particularly noticeable when there are lots of 'limited' /sync responses
// such as when laptops unsleep.
// https://github.com/vector-im/riot-web/issues/3307#issuecomment-282895568
// https://github.com/vector-im/element-web/issues/3307#issuecomment-282895568
cli.setCanResetTimelineCallback((roomId) => {
console.log("Request to reset timeline in room ", roomId, " viewing:", this.state.currentRoomId);
if (roomId !== this.state.currentRoomId) {
@ -1339,7 +1334,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// this if we are not scrolled up in the view. To find out, delegate to
// the timeline panel. If the timeline panel doesn't exist, then we assume
// it is safe to reset the timeline.
if (!this.loggedInView.current || !this.loggedInView.current) {
if (!this.loggedInView.current) {
return true;
}
return this.loggedInView.current.canResetTimelineInRoom(roomId);
@ -1372,10 +1367,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.firstSyncComplete = true;
this.firstSyncPromise.resolve();
dis.dispatch({action: 'focus_composer'});
if (Notifier.shouldShowToolbar()) {
showNotificationsToast();
}
dis.fire(Action.FocusComposer);
this.setState({
ready: true,
showNotifierToolbar: Notifier.shouldShowToolbar(),
});
});
cli.on('Call.incoming', function(call) {
@ -1396,7 +1394,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
title: _t('Signed Out'),
description: _t('For security, this session has been signed out. Please sign in again.'),
@ -1454,16 +1451,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
cli.on("Session.logged_out", () => dft.stop());
cli.on("Event.decrypted", (e, err) => dft.eventDecrypted(e, err));
// TODO: We can remove this once cross-signing is the only way.
// https://github.com/vector-im/riot-web/issues/11908
const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => {
krh.handleKeyRequest(req);
});
cli.on("crypto.roomKeyRequestCancellation", (req) => {
krh.handleKeyRequestCancellation(req);
});
cli.on("Room", (room) => {
if (MatrixClientPeg.get().isCryptoEnabled()) {
const blacklistEnabled = SettingsStore.getValueAt(
@ -1476,19 +1463,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
});
cli.on("crypto.warning", (type) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
switch (type) {
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
const brand = SdkConfig.get().brand;
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
title: _t('Old cryptography data detected'),
description: _t(
"Data from an older version of Riot has been detected. " +
"Data from an older version of %(brand)s has been detected. " +
"This will have caused end-to-end cryptography to malfunction " +
"in the older version. End-to-end encrypted messages exchanged " +
"recently whilst using the older version may not be decryptable " +
"in this version. This may also cause messages exchanged with this " +
"version to fail. If you experience problems, log out and back in " +
"again. To retain message history, export and re-import your keys.",
{ brand },
),
});
break;
@ -1534,13 +1522,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
cli.on("crypto.verification.request", request => {
const isFlagOn = SettingsStore.getValue("feature_cross_signing");
if (!isFlagOn && !request.channel.deviceId) {
request.cancel({code: "m.invalid_message", reason: "This client has cross-signing disabled"});
return;
}
if (request.verifier) {
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
@ -1553,7 +1534,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
icon: "verification",
props: {request},
component: sdk.getComponent("toasts.VerificationRequestToast"),
priority: ToastStore.PRIORITY_REALTIME,
priority: 90,
});
}
});
@ -1583,9 +1564,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// be aware of will be signalled through the room shield
// changing colour. More advanced behaviour will come once
// we implement more settings.
cli.setGlobalErrorOnUnknownDevices(
!SettingsStore.getValue("feature_cross_signing"),
);
cli.setGlobalErrorOnUnknownDevices(false);
}
}
@ -1621,9 +1600,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'view_create_room',
});
} else if (screen === 'settings') {
dis.dispatch({
action: 'view_user_settings',
});
dis.fire(Action.ViewUserSettings);
} else if (screen === 'welcome') {
dis.dispatch({
action: 'view_welcome_page',
@ -1638,9 +1615,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'require_registration',
});
} else if (screen === 'directory') {
dis.dispatch({
action: 'view_room_directory',
});
dis.fire(Action.ViewRoomDirectory);
} else if (screen === "start_sso" || screen === "start_cas") {
// TODO if logged in, skip SSO
let cli = MatrixClientPeg.get();
if (!cli) {
const {hsUrl, isUrl} = this.props.serverConfig;
cli = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
}
const type = screen === "start_sso" ? "sso" : "cas";
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
} else if (screen === 'groups') {
dis.dispatch({
action: 'view_my_groups',
@ -1673,7 +1661,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// of the app, we coerce the eventId to be undefined where applicable.
if (!eventId) eventId = undefined;
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/riot-web/issues/9149
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149
// FIXME: sort_out caseConsistency
const thirdPartyInvite = {
@ -1755,8 +1743,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const member = new RoomMember(null, userId);
if (!member) { return; }
dis.dispatch({
action: 'view_user',
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: member,
});
}
@ -1829,16 +1817,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.showScreen("settings");
};
onVersion(current: string, latest: string, releaseNotes?: string) {
this.setState({
version: current,
newVersion: latest,
hasNewVersion: current !== latest,
newVersionReleaseNotes: releaseNotes,
checkingForUpdate: null,
});
}
onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get();
if (!cli) {
@ -1863,23 +1841,24 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
subtitle = `${this.subTitleStatus} ${subtitle}`;
}
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`;
document.title = `${SdkConfig.get().brand} ${subtitle}`;
}
updateStatusIndicator(state: string, prevState: string) {
const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getRooms()).count;
const notificationState = RoomNotificationStateStore.instance.globalState;
const numUnreadRooms = notificationState.numUnreadStates; // we know that states === rooms here
if (PlatformPeg.get()) {
PlatformPeg.get().setErrorStatus(state === 'ERROR');
PlatformPeg.get().setNotificationCount(notifCount);
PlatformPeg.get().setNotificationCount(numUnreadRooms);
}
this.subTitleStatus = '';
if (state === "ERROR") {
this.subTitleStatus += `[${_t("Offline")}] `;
}
if (notifCount > 0) {
this.subTitleStatus += `[${notifCount}]`;
if (numUnreadRooms > 0) {
this.subTitleStatus += `[${numUnreadRooms}]`;
}
this.setPageSubtitle();
@ -1909,60 +1888,42 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.accountPasswordTimer = null;
}, 60 * 5 * 1000);
// Wait for the client to be logged in (but not started)
// which is enough to ask the server about account data.
const loggedIn = new Promise(resolve => {
const actionHandlerRef = dis.register(payload => {
if (payload.action !== "on_logged_in") {
return;
}
dis.unregister(actionHandlerRef);
resolve();
});
});
// Create and start the client in the background
const setLoggedInPromise = Lifecycle.setLoggedIn(credentials);
await loggedIn;
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
const cli = MatrixClientPeg.get();
// We're checking `isCryptoAvailable` here instead of `isCryptoEnabled`
// because the client hasn't been started yet.
const cryptoAvailable = isCryptoAvailable();
if (!cryptoAvailable) {
const cryptoEnabled = cli.isCryptoEnabled();
if (!cryptoEnabled) {
this.onLoggedIn();
}
this.setState({ pendingInitialSync: true });
await this.firstSyncPromise.promise;
if (!cryptoAvailable) {
this.setState({ pendingInitialSync: false });
return setLoggedInPromise;
const promisesList = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account
promisesList.push(cli.downloadKeys([cli.getUserId()]));
}
// Test for the master cross-signing key in SSSS as a quick proxy for
// whether cross-signing has been set up on the account.
const masterKeyInStorage = !!cli.getAccountData("m.cross_signing.master");
if (masterKeyInStorage) {
// Auto-enable cross-signing for the new session when key found in
// secret storage.
SettingsStore.setValue("feature_cross_signing", null, SettingLevel.DEVICE, true);
// Now update the state to say we're waiting for the first sync to complete rather
// than for the login to finish.
this.setState({ pendingInitialSync: true });
await Promise.all(promisesList);
if (!cryptoEnabled) {
this.setState({ pendingInitialSync: false });
return;
}
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
if (crossSigningIsSetUp) {
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
} else if (
SettingsStore.getValue("feature_cross_signing") &&
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
) {
// This will only work if the feature is set to 'enable' in the config,
// since it's too early in the lifecycle for users to have turned the
// labs flag on.
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
this.setStateForNewView({ view: Views.E2E_SETUP });
} else {
this.onLoggedIn();
}
this.setState({ pendingInitialSync: false });
return setLoggedInPromise;
};
// complete security / e2e setup has finished
@ -1970,9 +1931,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn();
};
render() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
getFragmentAfterLogin() {
let fragmentAfterLogin = "";
const initialScreenAfterLogin = this.props.initialScreenAfterLogin;
if (initialScreenAfterLogin &&
// XXX: workaround for https://github.com/vector-im/element-web/issues/11643 causing a login-loop
!["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen)
) {
fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`;
}
return fragmentAfterLogin;
}
render() {
const fragmentAfterLogin = this.getFragmentAfterLogin();
let view;
if (this.state.view === Views.LOADING) {
@ -2028,7 +2000,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onCloseAllSettings={this.onCloseAllSettings}
onRegistered={this.onRegistered}
currentRoomId={this.state.currentRoomId}
showCookieBar={this.state.showCookieBar}
/>
);
} else {
@ -2052,7 +2023,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
} else if (this.state.view === Views.WELCOME) {
const Welcome = sdk.getComponent('auth.Welcome');
view = <Welcome {...this.getServerProperties()} />;
view = <Welcome />;
} else if (this.state.view === Views.REGISTER) {
const Registration = sdk.getComponent('structures.auth.Registration');
view = (
@ -2091,6 +2062,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick}
onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin}
{...this.getServerProperties()}
/>
);
@ -2100,6 +2072,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
<SoftLogout
realQueryParams={this.props.realQueryParams}
onTokenLoginCompleted={this.props.onTokenLoginCompleted}
fragmentAfterLogin={fragmentAfterLogin}
/>
);
} else {

View file

@ -29,10 +29,35 @@ import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
// check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
function shouldFormContinuation(prevEvent, mxEvent) {
// sanity check inputs
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
// check if within the max continuation period
if (mxEvent.getTs() - prevEvent.getTs() > CONTINUATION_MAX_INTERVAL) return false;
// Some events should appear as continuations from previous events of different types.
if (mxEvent.getType() !== prevEvent.getType() &&
(!continuedTypes.includes(mxEvent.getType()) ||
!continuedTypes.includes(prevEvent.getType()))) return false;
// Check if the sender is the same and hasn't changed their displayname/avatar between these events
if (mxEvent.sender.userId !== prevEvent.sender.userId ||
mxEvent.sender.name !== prevEvent.sender.name ||
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
if (!haveTileForEvent(prevEvent)) return false;
return true;
}
const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite';
/* (almost) stateless UI component which builds the event tiles in the room timeline.
@ -107,10 +132,14 @@ export default class MessagePanel extends React.Component {
// whether to show reactions for an event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
};
constructor() {
super();
// Force props to be loaded for useIRCLayout
constructor(props) {
super(props);
this.state = {
// previous positions the read marker has been in, so we can
@ -317,9 +346,9 @@ export default class MessagePanel extends React.Component {
}
}
_isUnmounting() {
_isUnmounting = () => {
return !this._isMounted;
}
};
// TODO: Implement granular (per-room) hide options
_shouldShowEvent(mxEv) {
@ -359,8 +388,11 @@ export default class MessagePanel extends React.Component {
}
return (
<li key={"readMarker_"+eventId} ref={this._readMarkerNode}
className="mx_RoomView_myReadMarker_container">
<li key={"readMarker_"+eventId}
ref={this._readMarkerNode}
className="mx_RoomView_myReadMarker_container"
data-scroll-tokens={eventId}
>
{ hr }
</li>
);
@ -510,39 +542,6 @@ export default class MessagePanel extends React.Component {
const isEditing = this.props.editState &&
this.props.editState.getEvent().getId() === mxEv.getId();
// is this a continuation of the previous message?
let continuation = false;
// Some events should appear as continuations from previous events of
// different types.
const eventTypeContinues =
prevEvent !== null &&
continuedTypes.includes(mxEv.getType()) &&
continuedTypes.includes(prevEvent.getType());
// if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId &&
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
haveTileForEvent(prevEvent) && (mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
(mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) {
continuation = true;
}
/*
// Work out if this is still a continuation, as we are now showing commands
// and /me messages with their own little avatar. The case of a change of
// event type (commands) is handled above, but we need to handle the /me
// messages seperately as they have a msgtype of 'm.emote' but are classed
// as normal messages
if (prevEvent !== null && prevEvent.sender && mxEv.sender
&& mxEv.sender.userId === prevEvent.sender.userId
&& mxEv.getType() == prevEvent.getType()
&& prevEvent.getContent().msgtype === 'm.emote') {
continuation = false;
}
*/
// local echoes have a fake date, which could even be yesterday. Treat them
// as 'today' for the date separators.
@ -554,12 +553,15 @@ export default class MessagePanel extends React.Component {
}
// do we need a date separator since the last event?
if (this._wantsDateSeparator(prevEvent, eventDate)) {
const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
if (wantsDateSeparator) {
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
ret.push(dateSeparator);
continuation = false;
}
// is this a continuation of the previous message?
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv);
const eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId);
@ -569,12 +571,10 @@ export default class MessagePanel extends React.Component {
const readReceipts = this._readReceiptsByEvent[eventId];
// Dev note: `this._isUnmounting.bind(this)` is important - it ensures that
// the function is run in the context of this class and not EventTile, therefore
// ensuring the right `this._mounted` variable is used by read receipts (which
// don't update their position if we, the MessagePanel, is unmounting).
// use txnId as key if available so that we don't remount during sending
ret.push(
<li key={eventId}
<li
key={mxEv.getTxnId() || eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}
>
@ -588,7 +588,7 @@ export default class MessagePanel extends React.Component {
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting.bind(this)}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
@ -597,6 +597,7 @@ export default class MessagePanel extends React.Component {
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
useIRCLayout={this.props.useIRCLayout}
/>
</TileErrorBoundary>
</li>,
@ -792,6 +793,15 @@ export default class MessagePanel extends React.Component {
);
}
let ircResizer = null;
if (this.props.useIRCLayout) {
ircResizer = <IRCTimelineProfileResizer
minWidth={20}
maxWidth={600}
roomId={this.props.room ? this.props.room.roomId : null}
/>;
}
return (
<ErrorBoundary>
<ScrollPanel
@ -804,6 +814,7 @@ export default class MessagePanel extends React.Component {
style={style}
stickyBottom={this.props.stickyBottom}
resizeNotifier={this.props.resizeNotifier}
fixedChildren={ircResizer}
>
{ topSpinner }
{ this._getEventTiles() }

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -19,7 +20,8 @@ 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";
@ -60,6 +62,7 @@ export default createReactClass({
},
render: function() {
const brand = SdkConfig.get().brand;
const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const GroupTile = sdk.getComponent("groups.GroupTile");
@ -77,7 +80,8 @@ export default createReactClass({
<div className="mx_MyGroups_microcopy">
<p>
{ _t(
"Did you know: you can use communities to filter your Riot.im experience!",
"Did you know: you can use communities to filter your %(brand)s experience!",
{ brand },
) }
</p>
<p>

View file

@ -0,0 +1,63 @@
/*
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";
interface IProps {
}
interface IState {
toasts: ComponentClass[],
}
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>
);
}
}

View file

@ -36,6 +36,11 @@ const NotificationPanel = createReactClass({
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const Loader = sdk.getComponent("elements.Spinner");
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t('Youre all caught up')}</h2>
<p>{_t('You have no visible notifications in this room.')}</p>
</div>);
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
return (
@ -46,7 +51,7 @@ const NotificationPanel = createReactClass({
timelineSet={timelineSet}
showUrlPreview={false}
tileShape="notif"
empty={_t('You have no visible notifications')}
empty={emptyState}
/>
</div>
);

View file

@ -22,14 +22,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import dis from '../../dispatcher/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 {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases";
import RightPanelStore from "../../stores/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import {Action} from "../../dispatcher/actions";
export default class RightPanel extends React.Component {
static get propTypes() {
@ -75,8 +75,8 @@ export default class RightPanel extends React.Component {
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;
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList});
return RightPanelPhases.GroupMemberList;
}
return rps.groupPanelPhase;
} else if (userForPanel) {
@ -98,11 +98,11 @@ export default class RightPanel extends React.Component {
) {
return rps.roomPanelPhase;
}
return RIGHT_PANEL_PHASES.RoomMemberInfo;
return RightPanelPhases.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;
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList});
return RightPanelPhases.RoomMemberList;
}
return rps.roomPanelPhase;
}
@ -149,7 +149,7 @@ export default class RightPanel extends React.Component {
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: RIGHT_PANEL_PHASES.GroupMemberList,
phase: RightPanelPhases.GroupMemberList,
});
});
}
@ -165,9 +165,9 @@ export default class RightPanel extends React.Component {
return;
}
// redraw the badge on the membership list
if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList && member.roomId === this.props.roomId) {
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.roomId) {
this._delayedUpdate();
} else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo && member.roomId === this.props.roomId &&
} else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.roomId &&
member.userId === this.state.member.userId) {
// refresh the member info (e.g. new power level)
this._delayedUpdate();
@ -175,7 +175,7 @@ export default class RightPanel extends React.Component {
}
onAction(payload) {
if (payload.action === "after_right_panel_phase_change") {
if (payload.action === Action.AfterRightPanelPhaseChange) {
this.setState({
phase: payload.phase,
groupRoomId: payload.groupRoomId,
@ -188,113 +188,88 @@ export default class RightPanel extends React.Component {
}
}
onCloseUserInfo = () => {
// 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 {
// Otherwise we have got our user from RoomViewStore which means we're being shown
// within a room/group, so go back to the member panel if we were in the encryption panel,
// or the member list if we were in the member panel... phew.
dis.dispatch({
action: Action.ViewUser,
member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null,
});
}
};
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:
case RightPanelPhases.RoomMemberList:
if (this.props.roomId) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
}
break;
case RIGHT_PANEL_PHASES.GroupMemberList:
case RightPanelPhases.GroupMemberList:
if (this.props.groupId) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
}
break;
case RIGHT_PANEL_PHASES.GroupRoomList:
case RightPanelPhases.GroupRoomList:
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
break;
case RIGHT_PANEL_PHASES.RoomMemberInfo:
case RIGHT_PANEL_PHASES.EncryptionPanel:
if (SettingsStore.getValue("feature_cross_signing")) {
const 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 obviosuly 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 {
// Otherwise we have got our user from RoomViewStore which means we're being shown
// within a room, so go back to the member panel if we were in the encryption panel,
// or the member list if we were in the member panel... phew.
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}
/>;
}
case RightPanelPhases.RoomMemberInfo:
case RightPanelPhases.EncryptionPanel:
panel = <UserInfo
user={this.state.member}
roomId={this.props.roomId}
key={this.props.roomId || this.state.member.userId}
onClose={this.onCloseUserInfo}
phase={this.state.phase}
verificationRequest={this.state.verificationRequest}
verificationRequestPromise={this.state.verificationRequestPromise}
/>;
break;
case RIGHT_PANEL_PHASES.Room3pidMemberInfo:
case RightPanelPhases.Room3pidMemberInfo:
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
break;
case RIGHT_PANEL_PHASES.GroupMemberInfo:
if (SettingsStore.getValue("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}
/>
);
}
case RightPanelPhases.GroupMemberInfo:
panel = <UserInfo
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.userId}
onClose={this.onCloseUserInfo} />;
break;
case RIGHT_PANEL_PHASES.GroupRoomInfo:
case RightPanelPhases.GroupRoomInfo:
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
break;
case RIGHT_PANEL_PHASES.NotificationPanel:
case RightPanelPhases.NotificationPanel:
panel = <NotificationPanel />;
break;
case RIGHT_PANEL_PHASES.FilePanel:
case RightPanelPhases.FilePanel:
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
break;
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,11 +20,12 @@ import React from 'react';
import createReactClass from 'create-react-class';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import dis from "../../dispatcher";
import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal";
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
@ -74,7 +75,7 @@ export default createReactClass({
this.protocols = response;
this.setState({protocolsLoading: false});
}, (err) => {
console.warn(`error loading thirdparty protocols: ${err}`);
console.warn(`error loading third party protocols: ${err}`);
this.setState({protocolsLoading: false});
if (MatrixClientPeg.get().isGuest()) {
// Guests currently aren't allowed to use this API, so
@ -83,10 +84,12 @@ export default createReactClass({
return;
}
track('Failed to get protocol list from homeserver');
const brand = SdkConfig.get().brand;
this.setState({
error: _t(
'Riot failed to get the protocol list from the homeserver. ' +
'%(brand)s failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.',
{ brand },
),
});
});
@ -173,12 +176,13 @@ export default createReactClass({
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
track('Failed to get public room list');
const brand = SdkConfig.get().brand;
this.setState({
loading: false,
error:
`${_t('Riot failed to get the public room list.')} ` +
`${(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')}`
,
error: (
_t('%(brand)s failed to get the public room list.', { brand }) +
(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')
),
});
});
},
@ -199,7 +203,7 @@ export default createReactClass({
let desc;
if (alias) {
desc = _t('Delete the room alias %(alias)s and remove %(name)s from the directory?', {alias: alias, name: name});
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});
}
@ -216,7 +220,7 @@ export default createReactClass({
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
if (!alias) return;
step = _t('delete the alias.');
step = _t('delete the address.');
return MatrixClientPeg.get().deleteAlias(alias);
}).then(() => {
modal.close();
@ -314,9 +318,10 @@ export default createReactClass({
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
if (!fields) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const brand = SdkConfig.get().brand;
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
title: _t('Unable to join network'),
description: _t('Riot does not know how to join a room on this network'),
description: _t('%(brand)s does not know how to join a room on this network', { brand }),
});
return;
}
@ -367,7 +372,10 @@ export default createReactClass({
onCreateRoomClick: function(room) {
this.props.onFinished();
dis.dispatch({action: 'view_create_room'});
dis.dispatch({
action: 'view_create_room',
public: true,
});
},
showRoomAlias: function(alias, autoJoin=false) {

View file

@ -0,0 +1,185 @@
/*
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 defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import { ActionPayload } from "../../dispatcher/payloads";
import { throttle } from 'lodash';
import { Key } from "../../Keyboard";
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";
interface IProps {
isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent): void;
onEnter(ev: React.KeyboardEvent): boolean;
}
interface IState {
query: string;
focused: boolean;
}
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,
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
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);
}
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) => {
if (ev.key === Key.ESCAPE) {
this.clearInput();
defaultDispatcher.fire(Action.FocusComposer);
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
this.props.onVerticalArrow(ev);
} else if (ev.key === Key.ENTER) {
const shouldClear = this.props.onEnter(ev);
if (shouldClear) {
// wrap in set immediate to delay it so that we don't clear the filter & then change room
setImmediate(() => {
this.clearInput();
});
}
}
};
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 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={_t("Search")}
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("Search rooms")}
className="mx_RoomSearch_icon"
onClick={this.openSearch}
/>
);
input = null;
clearButton = null;
}
return (
<div className={classes}>
{icon}
{input}
{clearButton}
</div>
);
}
}

View file

@ -24,9 +24,9 @@ import { _t, _td } from '../../languageHandler';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -126,25 +126,14 @@ export default createReactClass({
});
},
_onSendWithoutVerifyingClick: function() {
cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => {
cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices);
Resend.resendUnsentEvents(this.props.room);
});
},
_onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
},
_onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
},
_onShowDevicesClick: function() {
cryptodevices.showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
dis.fire(Action.FocusComposer);
},
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
@ -184,7 +173,7 @@ export default createReactClass({
if (this.props.hasActiveCall) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src={require("../../../res/img/sound-indicator.svg")} width="23" height="20" />
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
);
}
@ -213,82 +202,65 @@ export default createReactClass({
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 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.",
),
'': _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 {
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, {
'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.",
),
'': _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>,
},
);
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
}
const 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>,
},
);
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>

View file

@ -1,460 +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";
import toRem from "../../utils/rem";
// 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 = toRem(height);
}
this._updateLazyRenderHeight(height);
};
_updateLazyRenderHeight(height) {
this.setState({scrollerHeight: height});
}
_onScroll = () => {
this.setState({scrollTop: this._scroller.current.getScrollTop()});
};
_canUseLazyListRendering() {
// for now disable lazy rendering as they are already rendered tiles
// not rooms like props.list we pass to LazyRenderList
return !this.props.extraTiles || !this.props.extraTiles.length;
}
render() {
const len = this.props.list.length + this.props.extraTiles.length;
const isCollapsed = this.state.hidden && !this.props.forceExpand;
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": len && isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
let content;
if (len) {
if (isCollapsed) {
// no body
} else if (this._canUseLazyListRendering()) {
content = (
<IndicatorScrollbar ref={this._scroller} className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
<LazyRenderList
scrollTop={this.state.scrollTop }
height={ this.state.scrollerHeight }
renderItem={ this.makeRoomTile }
itemHeight={34}
items={ this.props.list } />
</IndicatorScrollbar>
);
} else {
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
const tiles = roomTiles.concat(this.props.extraTiles);
content = (
<IndicatorScrollbar ref={this._scroller} className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
{ tiles }
</IndicatorScrollbar>
);
}
} else {
if (this.props.showSpinner && !isCollapsed) {
const Loader = sdk.getComponent("elements.Spinner");
content = <Loader />;
}
}
return (
<div
ref={this._subList}
className={subListClasses}
role="group"
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
>
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);
}
}

View file

@ -34,27 +34,29 @@ import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import * as sdk from '../../index';
import CallHandler from '../../CallHandler';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import Tinter from '../../Tinter';
import rate_limited_func from '../../ratelimitedfunc';
import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch from '../../Searching';
import eventSearch, {searchPagination} from '../../Searching';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import SettingsStore from "../../settings/SettingsStore";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import {haveTileForEvent} from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { shieldStatusForRoom } from '../../utils/ShieldUtils';
import {Action} from "../../dispatcher/actions";
import {SettingLevel} from "../../settings/SettingLevel";
const DEBUG = false;
let debuglog = function() {};
@ -164,6 +166,10 @@ export default createReactClass({
canReact: false,
canReply: false,
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
};
},
@ -193,6 +199,8 @@ export default createReactClass({
this._roomView = createRef();
this._searchResultsPanel = createRef();
this._layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
},
_onReadReceiptsChange: function() {
@ -232,7 +240,8 @@ export default createReactClass({
initialEventId: RoomViewStore.getInitialEventId(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
shouldPeek: RoomViewStore.shouldPeek(),
// we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
};
@ -242,7 +251,7 @@ export default createReactClass({
this.context.stopPeeking();
}
// Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307
// Temporary logging to diagnose https://github.com/vector-im/element-web/issues/4307
console.log(
'RVS update:',
newState.roomId,
@ -532,6 +541,14 @@ export default createReactClass({
// no need to do this as Dir & Settings are now overlays. It just burnt CPU.
// console.log("Tinter.tint from RoomView.unmount");
// Tinter.tint(); // reset colourscheme
SettingsStore.unwatchSetting(this._layoutWatcherRef);
},
onLayoutChange: function() {
this.setState({
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
});
},
_onRightPanelStoreUpdate: function() {
@ -588,6 +605,18 @@ export default createReactClass({
handled = true;
}
break;
case Key.PAGE_UP:
if (!ev.altKey && !ev.ctrlKey && ev.shiftKey && !ev.metaKey) {
this.jumpToReadMarker();
handled = true;
}
break;
case Key.U.toUpperCase():
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) {
dis.dispatch({ action: "upload_file" })
handled = true;
}
break;
}
if (handled) {
@ -669,6 +698,16 @@ export default createReactClass({
});
}
break;
case 'sync_state':
if (!this.state.matrixClientIsReady) {
this.setState({
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
}, () => {
// send another "initial" RVS update to trigger peeking if needed
this._onRoomViewStoreUpdate(true);
});
}
break;
}
},
@ -842,15 +881,6 @@ export default createReactClass({
});
return;
}
if (!SettingsStore.getValue("feature_cross_signing")) {
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
this.setState({
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
});
});
debuglog("e2e check is warning/verified only as cross-signing is off");
return;
}
/* At this point, the user has encryption on and cross-signing on */
this.setState({
@ -1007,8 +1037,7 @@ export default createReactClass({
if (this.state.searchResults.next_batch) {
debuglog("requesting more search results");
const searchPromise = this.context.backPaginateRoomEventsSearch(
this.state.searchResults);
const searchPromise = searchPagination(this.state.searchResults);
return this._handleSearchResult(searchPromise);
} else {
debuglog("no more search results");
@ -1049,7 +1078,7 @@ export default createReactClass({
// Do this by indicating our intention to join
// XXX: ILAG is disabled for now,
// see https://github.com/vector-im/riot-web/issues/8222
// see https://github.com/vector-im/element-web/issues/8222
dis.dispatch({action: 'require_registration'});
// dis.dispatch({
// action: 'will_join',
@ -1134,7 +1163,7 @@ export default createReactClass({
ev.dataTransfer.files, this.state.room.roomId, this.context,
);
this.setState({ draggingFile: false });
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
},
onDragLeaveOrEnd: function(ev) {
@ -1285,6 +1314,14 @@ export default createReactClass({
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId();
const room = this.context.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// As per the spec, an all rooms search can create this condition,
// it happens with Seshat but not Synapse.
// It will make the result count not match the displayed count.
console.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count
@ -1293,16 +1330,9 @@ export default createReactClass({
}
if (this.state.searchScope === 'All') {
if (roomId != lastRoomId) {
// XXX: if we've left the room, we might not know about
// it. We should tell the js sdk to go and find out about
// it. But that's not an issue currently, as synapse only
// returns results for rooms we're joined to.
const roomName = room ? room.name : _t("Unknown room %(roomId)s", { roomId: roomId });
if (roomId !== lastRoomId) {
ret.push(<li key={mxEv.getId() + "-room"}>
<h2>{ _t("Room") }: { roomName }</h2>
<h2>{ _t("Room") }: { room.name }</h2>
</li>);
lastRoomId = roomId;
}
@ -1340,7 +1370,7 @@ export default createReactClass({
event: null,
});
}
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
},
onLeaveClick: function() {
@ -1351,15 +1381,9 @@ export default createReactClass({
},
onForgetClick: function() {
this.context.forget(this.state.room.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' });
}, function(err) {
const errCode = err.errcode || _t("unknown error code");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, {
title: _t("Error"),
description: _t("Failed to forget room %(errCode)s", { errCode: errCode }),
});
dis.dispatch({
action: 'forget_room',
room_id: this.state.room.roomId,
});
},
@ -1429,9 +1453,7 @@ export default createReactClass({
// using /leave rather than /join. In the short term though, we
// just ignore them.
// https://github.com/vector-im/vector-web/issues/1134
dis.dispatch({
action: 'view_room_directory',
});
dis.fire(Action.ViewRoomDirectory);
},
onSearchClick: function() {
@ -1451,7 +1473,7 @@ export default createReactClass({
// jump down to the bottom of this room, where new events are arriving
jumpToLiveTimeline: function() {
this._messagePanel.jumpToLiveTimeline();
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
},
// jump up to wherever our read marker is
@ -1651,14 +1673,16 @@ export default createReactClass({
const ErrorBoundary = sdk.getComponent("elements.ErrorBoundary");
if (!this.state.room) {
const loading = this.state.roomLoading || this.state.peekLoading;
const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading;
if (loading) {
// Assume preview loading if we don't have a ready client or a room ID (still resolving the alias)
const previewLoading = !this.state.matrixClientIsReady || !this.state.roomId || this.state.peekLoading;
return (
<div className="mx_RoomView">
<ErrorBoundary>
<RoomPreviewBar
canPreview={false}
previewLoading={this.state.peekLoading}
previewLoading={previewLoading && !this.state.roomLoadError}
error={this.state.roomLoadError}
loading={loading}
joining={this.state.joining}
@ -1683,7 +1707,8 @@ export default createReactClass({
return (
<div className="mx_RoomView">
<ErrorBoundary>
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
@ -1717,8 +1742,11 @@ export default createReactClass({
} else {
const myUserId = this.context.credentials.userId;
const myMember = this.state.room.getMember(myUserId);
const inviteEvent = myMember.events.member;
var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
const inviteEvent = myMember ? myMember.events.member : null;
let inviterName = _t("Unknown");
if (inviteEvent) {
inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
}
// We deliberately don't try to peek into invites, even if we have permission to peek
// as they could be a spam vector.
@ -1786,6 +1814,7 @@ export default createReactClass({
);
const showRoomRecoveryReminder = (
this.context.isCryptoEnabled() &&
SettingsStore.getValue("showRoomRecoveryReminder") &&
this.context.isRoomEncrypted(this.state.room.roomId) &&
this.context.getKeyBackupEnabled() === false
@ -1884,6 +1913,7 @@ export default createReactClass({
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
}
@ -1899,25 +1929,29 @@ export default createReactClass({
}
if (inCall) {
let zoomButton; let voiceMuteButton; let videoMuteButton;
let zoomButton; let videoMuteButton;
if (call.type === "video") {
zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg src={require("../../../res/img/fullscreen.svg")} width="29" height="22" style={{ marginTop: 1, marginRight: 4 }} />
<TintableSvg src={require("../../../res/img/element-icons/call/fullscreen.svg")} width="29" height="22" style={{ marginTop: 1, marginRight: 4 }} />
</div>
);
videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg src={call.isLocalVideoMuted() ? require("../../../res/img/video-unmute.svg") : require("../../../res/img/video-mute.svg")}
<TintableSvg src={call.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")}
alt={call.isLocalVideoMuted() ? _t("Click to unmute video") : _t("Click to mute video")}
width="31" height="27" />
width="" height="27" />
</div>;
}
voiceMuteButton =
const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg src={call.isMicrophoneMuted() ? require("../../../res/img/voice-unmute.svg") : require("../../../res/img/voice-mute.svg")}
<TintableSvg src={call.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={call.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21" height="26" />
</div>;
@ -1929,7 +1963,6 @@ export default createReactClass({
{ videoMuteButton }
{ zoomButton }
{ statusBar }
<TintableSvg className="mx_RoomView_voipChevron" src={require("../../../res/img/voip-chevron.svg")} width="22" height="17" />
</div>;
}
@ -1944,8 +1977,9 @@ export default createReactClass({
searchResultsPanel = (<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />);
} else {
searchResultsPanel = (
<ScrollPanel ref={this._searchResultsPanel}
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
<ScrollPanel
ref={this._searchResultsPanel}
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel mx_GroupLayout"
onFillRequest={this.onSearchResultsFillRequest}
resizeNotifier={this.props.resizeNotifier}
>
@ -1965,6 +1999,13 @@ export default createReactClass({
highlightedEventId = this.state.initialEventId;
}
const messagePanelClassNames = classNames(
"mx_RoomView_messagePanel",
{
"mx_IRCLayout": this.state.useIRCLayout,
"mx_GroupLayout": !this.state.useIRCLayout,
});
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
const messagePanel = (
<TimelinePanel
@ -1980,11 +2021,12 @@ export default createReactClass({
onScroll={this.onMessageListScroll}
onReadMarkerUpdated={this._updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview}
className="mx_RoomView_messagePanel"
className={messagePanelClassNames}
membersLoaded={this.state.membersLoaded}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
resizeNotifier={this.props.resizeNotifier}
showReactions={true}
useIRCLayout={this.state.useIRCLayout}
/>);
let topUnreadMessagesBar = null;
@ -2001,6 +2043,7 @@ export default createReactClass({
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = (<JumpToBottomButton
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
/>);

View file

@ -144,6 +144,11 @@ export default createReactClass({
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier: PropTypes.object,
/* fixedChildren: allows for children to be passed which are rendered outside
* of the wrapper
*/
fixedChildren: PropTypes.node,
},
getDefaultProps: function() {
@ -643,7 +648,9 @@ export default createReactClass({
if (scrollState.stuckAtBottom) {
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
if (sn.scrollTop !== sn.scrollHeight) {
sn.scrollTop = sn.scrollHeight;
}
} else if (scrollState.trackedScrollToken) {
const itemlist = this._itemlist.current;
const trackedNode = this._getTrackedNode();
@ -652,7 +659,10 @@ export default createReactClass({
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
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);
}
}
@ -689,12 +699,16 @@ export default createReactClass({
const height = Math.max(minHeight, contentHeight);
this._pages = Math.ceil(height / PAGE_SIZE);
this._bottomGrowth = 0;
const newHeight = this._getListHeight();
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();
@ -704,7 +718,9 @@ export default createReactClass({
// 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
@ -881,6 +897,7 @@ export default createReactClass({
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll}
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 }

View file

@ -19,7 +19,7 @@ import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
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';

View file

@ -27,25 +27,20 @@ import { ReactNode } from "react";
* 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) {
}
}
interface IProps {
tabs: Tab[];
initialTabId?: string;
}
interface IState {
@ -53,16 +48,17 @@ interface IState {
}
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,
};
}

View file

@ -22,7 +22,7 @@ import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
@ -141,7 +141,7 @@ const TagPanel = createReactClass({
<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
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown}
>
<Droppable

View file

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

View file

@ -29,7 +29,7 @@ import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as ObjectUtils from "../../ObjectUtils";
import UserActivity from "../../UserActivity";
import Modal from "../../Modal";
import dis from "../../dispatcher";
import dis from "../../dispatcher/dispatcher";
import * as sdk from "../../index";
import { Key } from '../../Keyboard';
import Timer from '../../utils/Timer';
@ -112,6 +112,9 @@ const TimelinePanel = createReactClass({
// whether to show reactions for an event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
},
statics: {
@ -795,6 +798,9 @@ const TimelinePanel = createReactClass({
readMarkerVisible: false,
});
}
// Send the updated read marker (along with read receipt) to the server
this.sendReadReceipt();
},
@ -1180,7 +1186,7 @@ const TimelinePanel = createReactClass({
if (!timeline) {
// Somehow, it seems to be possible for live events to not have
// a timeline, even though that should not happen. :(
// https://github.com/vector-im/riot-web/issues/12120
// https://github.com/vector-im/element-web/issues/12120
console.warn(
`Event ${events[i].getId()} in room ${room.roomId} is live, ` +
`but it does not have a timeline`,
@ -1447,6 +1453,7 @@ const TimelinePanel = createReactClass({
getRelationsForEvent={this.getRelationsForEvent}
editState={this.state.editState}
showReactions={this.props.showReactions}
useIRCLayout={this.props.useIRCLayout}
/>
);
},

View file

@ -15,14 +15,21 @@ 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";
export default class ToastContainer extends React.Component {
constructor() {
super();
this.state = {toasts: ToastStore.sharedInstance().getToasts()};
interface IState {
toasts: IToast<any>[];
countSeen: number;
}
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,7 +43,10 @@ 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() {
@ -50,14 +60,21 @@ export default class ToastContainer extends React.Component {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
});
const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null;
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>);
}

View file

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

View file

@ -19,7 +19,7 @@ import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import ContentMessages from '../../ContentMessages';
import dis from "../../dispatcher";
import dis from "../../dispatcher/dispatcher";
import filesize from "filesize";
import { _t } from '../../languageHandler';

View file

@ -0,0 +1,344 @@
/*
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, { createRef } from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink";
import {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import BaseAvatar from '../views/avatars/BaseAvatar';
import classNames from "classnames";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { SettingLevel } from "../../settings/SettingLevel";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList
} from "../views/context_menus/IconizedContextMenu";
interface IProps {
isMinimized: boolean;
}
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
interface IState {
contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean;
}
export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
}
private get hasHomePage(): boolean {
return !!getHomePageUrl(SdkConfig.get());
}
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
}
public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
}
private isUserOnDarkTheme(): boolean {
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 onThemeChanged = () => {
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
};
private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested
if (this.state.contextMenuPosition) {
this.setState({contextMenuPosition: null});
} else {
if (this.buttonRef.current) this.buttonRef.current.click();
}
};
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('Report bugs & give feedback', '', RedesignFeedbackDialog);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onSignOutClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({action: 'view_home_page'});
};
private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null;
let hostingLink;
const signupLink = getHostingLink("user-context-menu");
if (signupLink) {
hostingLink = (
<div className="mx_UserMenu_contextMenu_header">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</div>
);
}
let homeButton = null;
if (this.hasHomePage) {
homeButton = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconHome"
label={_t("Home")}
onClick={this.onHomeClick}
/>
);
}
return <IconizedContextMenu
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
className="mx_UserMenu_contextMenu"
>
<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>
<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>
{hostingLink}
<IconizedContextMenuOptionList>
{homeButton}
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notification settings")}
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & privacy")}
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
/>
<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}
/> */}
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")}
onClick={this.onProvideFeedback}
/>
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
public render() {
const avatarSize = 32; // should match border-radius of the avatar
let name = <span className="mx_UserMenu_userName">{OwnProfileStore.instance.displayName}</span>;
let buttons = (
<span className="mx_UserMenu_headerButtons">
{/* masked image in CSS */}
</span>
);
if (this.props.isMinimized) {
name = null;
buttons = null;
}
const classes = classNames({
'mx_UserMenu': true,
'mx_UserMenu_minimized': this.props.isMinimized,
});
return (
<React.Fragment>
<ContextMenuButton
className={classes}
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("User menu")}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
>
<div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId()}
url={OwnProfileStore.instance.getHttpAvatarUrl(avatarSize)}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar"
/>
</span>
{name}
{buttons}
</div>
</ContextMenuButton>
{this.renderContextMenu()}
</React.Fragment>
);
}
}

View file

@ -22,6 +22,7 @@ import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import HomePage from "./HomePage";
export default class UserView extends React.Component {
static get propTypes() {
@ -42,7 +43,10 @@ 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();
}
}
@ -76,7 +80,7 @@ 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}><HomePage /></MainSplit>);
} else {
return (<div />);
}

View file

@ -285,7 +285,7 @@ export default createReactClass({
// 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.
// 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.
@ -355,7 +355,8 @@ export default createReactClass({
ev.preventDefault();
ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind);
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind,
this.props.fragmentAfterLogin);
} else {
// Don't intercept - just go through to the register page
this.onRegisterClick(ev);
@ -614,8 +615,8 @@ export default createReactClass({
}
// 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
// we use the same window and redirect back to Element. 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
@ -628,7 +629,9 @@ export default createReactClass({
<SSOButton
className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()}
loginType={loginType} />
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
</div>
);
},

View file

@ -32,7 +32,7 @@ import * as Lifecycle from '../../../Lifecycle';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login from "../../../Login";
import dis from "../../../dispatcher";
import dis from "../../../dispatcher/dispatcher";
// Phases
// Show controls to configure server details
@ -243,10 +243,14 @@ export default createReactClass({
});
};
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!");
// 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({
@ -258,7 +262,7 @@ export default createReactClass({
// 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
defaultDeviceDisplayName: "Element 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');
@ -267,6 +271,7 @@ export default createReactClass({
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: [],
@ -373,7 +378,7 @@ export default createReactClass({
}
if (response.access_token) {
const cli = await this.props.onLoggedIn({
await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
@ -381,7 +386,7 @@ export default createReactClass({
accessToken: response.access_token,
}, this.state.formVals.password);
this._setupPushers(cli);
this._setupPushers();
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
} else {
@ -392,10 +397,11 @@ export default createReactClass({
this.setState(newState);
},
_setupPushers: function(matrixClient) {
_setupPushers: function() {
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) {

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import {
@ -28,6 +29,14 @@ import {
PHASE_FINISHED,
} from '../../../stores/SetupEncryptionStore';
function keyHasPassphrase(keyInfo) {
return (
keyInfo.passphrase &&
keyInfo.passphrase.salt &&
keyInfo.passphrase.iterations
);
}
export default class SetupEncryptionBody extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
@ -108,6 +117,23 @@ export default class SetupEncryptionBody extends React.Component {
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
/>;
} else if (phase === PHASE_INTRO) {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Use Recovery Key or Passphrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("Use Recovery Key");
}
let useRecoveryKeyButton;
if (recoveryKeyPrompt) {
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this._onUsePassphraseClick}>
{recoveryKeyPrompt}
</AccessibleButton>;
}
const brand = SdkConfig.get().brand;
return (
<div>
<p>{_t(
@ -115,25 +141,24 @@ export default class SetupEncryptionBody extends React.Component {
"granting it access to encrypted messages.",
)}</p>
<p>{_t(
"This requires the latest Riot on your other devices:",
"This requires the latest %(brand)s on your other devices:",
{ brand },
)}</p>
<div className="mx_CompleteSecurity_clients">
<div className="mx_CompleteSecurity_clients_desktop">
<div>Riot Web</div>
<div>Riot Desktop</div>
<div>{_t("%(brand)s Web", { brand })}</div>
<div>{_t("%(brand)s Desktop", { brand })}</div>
</div>
<div className="mx_CompleteSecurity_clients_mobile">
<div>Riot iOS</div>
<div>Riot X for Android</div>
<div>{_t("%(brand)s iOS", { brand })}</div>
<div>{_t("%(brand)s Android", { brand })}</div>
</div>
<p>{_t("or another cross-signing capable Matrix client")}</p>
</div>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="link" onClick={this._onUsePassphraseClick}>
{_t("Use Recovery Passphrase or Key")}
</AccessibleButton>
{useRecoveryKeyButton}
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
{_t("Skip")}
</AccessibleButton>

View file

@ -18,13 +18,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {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";
const LOGIN_VIEW = {
LOADING: 1,
@ -43,7 +44,7 @@ const FLOWS_TO_VIEWS = {
export default class SoftLogout extends React.Component {
static propTypes = {
// Query parameters from MatrixChat
realQueryParams: PropTypes.object, // {homeserver, identityServer, loginToken}
realQueryParams: PropTypes.object, // {loginToken}
// Called when the SSO login completes
onTokenLoginCompleted: PropTypes.func,
@ -71,7 +72,7 @@ export default class SoftLogout extends React.Component {
this._initLogin();
MatrixClientPeg.get().flagAllGroupSessionsForBackup().then(remaining => {
MatrixClientPeg.get().countSessionsNeedingBackup().then(remaining => {
this.setState({keyBackupNeeded: remaining > 0});
});
}
@ -90,7 +91,7 @@ export default class SoftLogout extends React.Component {
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.trySsoLogin();
@ -157,8 +158,8 @@ export default class SoftLogout extends React.Component {
async trySsoLogin() {
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'],
@ -244,7 +245,9 @@ export default class SoftLogout extends React.Component {
<p>{introText}</p>
<SSOButton
matrixClient={MatrixClientPeg.get()}
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"} />
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
</div>
);
}

View file

@ -19,7 +19,7 @@ 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";
@ -80,7 +80,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) {

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,11 +18,13 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default createReactClass({
displayName: 'CustomServerDialog',
render: function() {
const brand = SdkConfig.get().brand;
return (
<div className="mx_ErrorDialog">
<div className="mx_Dialog_title">
@ -32,8 +34,9 @@ export default createReactClass({
<p>{_t(
"You can use the custom server options to sign into other " +
"Matrix servers by specifying a different homeserver URL. This " +
"allows you to use this app with an existing Matrix account on a " +
"allows you to use %(brand)s with an existing Matrix account on a " +
"different homeserver.",
{ brand },
)}</p>
</div>
<div className="mx_Dialog_buttons">

View file

@ -109,7 +109,7 @@ export const PasswordAuthEntry = createReactClass({
this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE,
// 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",
@ -355,6 +355,7 @@ 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} />
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
@ -412,14 +413,14 @@ export const EmailIdentityAuthEntry = createReactClass({
this.props.onPhaseChange(DEFAULT_PHASE);
},
getInitialState: function() {
return {
requestingToken: false,
};
},
render: function() {
if (this.state.requestingToken) {
// 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) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
@ -537,7 +538,8 @@ export const MsisdnAuthEntry = createReactClass({
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
// 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,
});

View file

@ -16,10 +16,11 @@ limitations under the License.
import SdkConfig from "../../../SdkConfig";
import {getCurrentLanguage} from "../../../languageHandler";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
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) {

View file

@ -23,7 +23,8 @@ 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';
const MODULAR_URL = 'https://element.io/matrix-services' +
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
// TODO: TravisR - Can this extend ServerConfig for most things?
@ -94,10 +95,10 @@ export default class ModularServerConfig extends ServerConfig {
return (
<div className="mx_ServerConfig">
<h3>{_t("Your Modular server")}</h3>
<h3>{_t("Your server")}</h3>
{_t(
"Enter the location of your Modular homeserver. It may use your own " +
"domain name or be a subdomain of <a>modular.im</a>.",
"Enter the location of your Element Matrix Services homeserver. It may use your own " +
"domain name or be a subdomain of <a>element.io</a>.",
{}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
{sub}

View file

@ -0,0 +1,125 @@
/*
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 from "../elements/Field";
interface IProps {
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);
}
interface IState {
complexity: zxcvbn.ZXCVBNResult;
}
class PassphraseField extends PureComponent<IProps, IState> {
static defaultProps = {
label: _td("Password"),
labelEnterPassword: _td("Enter password"),
labelStrongPassword: _td("Nice, strong password!"),
labelAllowedButUnsafe: _td("Password is allowed, but unsafe"),
};
state = { complexity: null };
public readonly validate = withValidation<this>({
description: function() {
const complexity = this.state.complexity;
const score = complexity ? complexity.score : 0;
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t(this.props.labelEnterPassword),
},
{
key: "complexity",
test: async function({ value }) {
if (!value) {
return false;
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
this.setState({ complexity });
const safe = complexity.score >= this.props.minScore;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
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.complexity.score >= this.props.minScore) {
return _t(this.props.labelStrongPassword);
}
return _t(this.props.labelAllowedButUnsafe);
},
invalid: function() {
const complexity = this.state.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;

View file

@ -238,7 +238,7 @@ export default class PasswordLogin extends React.Component {
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefix={phoneCountry}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}

View file

@ -29,6 +29,7 @@ import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField";
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
@ -76,9 +77,8 @@ export default createReactClass({
email: this.props.defaultEmail || "",
phoneNumber: this.props.defaultPhoneNumber || "",
password: this.props.defaultPassword || "",
passwordConfirm: "",
passwordConfirm: this.props.defaultPassword || "",
passwordComplexity: null,
passwordSafe: false,
};
},
@ -264,65 +264,10 @@ export default createReactClass({
});
},
async onPasswordValidate(fieldState) {
const result = await this.validatePasswordRules(fieldState);
onPasswordValidate(result) {
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,
@ -484,13 +429,10 @@ export default createReactClass({
},
renderPassword() {
const Field = sdk.getComponent('elements.Field');
return <Field
return <PassphraseField
id="mx_RegistrationForm_password"
ref={field => this[FIELD_PASSWORD] = field}
type="password"
autoComplete="new-password"
label={_t("Password")}
fieldRef={field => this[FIELD_PASSWORD] = field}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
@ -531,7 +473,7 @@ export default createReactClass({
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
prefix={phoneCountry}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>;

View file

@ -22,7 +22,8 @@ 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';
const MODULAR_URL = 'https://element.io/matrix-services' +
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
export const FREE = 'Free';
export const PREMIUM = 'Premium';
@ -44,7 +45,7 @@ export const TYPES = {
PREMIUM: {
id: PREMIUM,
label: () => _t('Premium'),
logo: () => <img src={require('../../../../res/img/modular-bw-logo.svg')} />,
logo: () => <img src={require('../../../../res/img/ems-logo.svg')} height={16} />,
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
{sub}

View file

@ -18,9 +18,7 @@ import React from 'react';
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";
// translatable strings for Welcome pages
_td("Sign in with SSO");
@ -39,14 +37,6 @@ 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">
@ -54,8 +44,8 @@ export default class Welcome extends React.PureComponent {
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 />

View file

@ -1,236 +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";
import toRem from "../../../utils/rem";
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: toRem(width * 0.65),
width: toRem(width),
lineHeight: toRem(height),
}}
>
{ initialLetter }
</span>
);
const imgNode = (
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
aria-hidden="true"
style={{
width: toRem(width),
height: toRem(height)
}} />
);
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}
style={{
width: toRem(width),
height: toRem(height),
}}
title={title} alt=""
inputRef={inputRef}
{...otherProps} />
);
} else {
return (
<img
className="mx_BaseAvatar mx_BaseAvatar_image"
src={imageUrl}
onError={this.onError}
style={{
width: toRem(width),
height: toRem(height),
}}
title={title} alt=""
ref={inputRef}
{...otherProps} />
);
}
},
});

View file

@ -0,0 +1,199 @@
/*
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, useMemo, useState} from 'react';
import classNames from 'classnames';
import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
interface IProps {
name: string; // The name (first initial used as default)
idName?: string; // ID for generating hash colours
title?: string; // onHover title text
url?: string; // highest priority of them all, shortcut to set in urls[0]
urls?: string[]; // [highest_priority, ... , lowest_priority]
width?: number;
height?: number;
// XXX: resizeMethod not actually used.
resizeMethod?: string;
defaultToInitialLetter?: boolean; // true to add default url
onClick?: React.MouseEventHandler;
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
className?: string;
}
const useImageUrl = ({url, urls}): [string, () => void] => {
const [imageUrls, setUrls] = useState<string[]>([]);
const [urlsIndex, setIndex] = useState<number>();
const onError = useCallback(() => {
setIndex(i => i + 1); // try the next one
}, []);
const memoizedUrls = useMemo(() => urls, [JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
// work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, ...props.urls ]
let _urls = [];
if (!SettingsStore.getValue("lowBandwidth")) {
_urls = memoizedUrls || [];
if (url) {
_urls.unshift(url); // put in urls[0]
}
}
// deduplicate URLs
_urls = Array.from(new Set(_urls));
setIndex(0);
setUrls(_urls);
}, [url, memoizedUrls]); // 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 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
{...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=""
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>;

View file

@ -0,0 +1,208 @@
/*
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 { TagID } from '../../../stores/room-list/models';
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";
interface IProps {
room: Room;
avatarSize: number;
tag: TagID;
displayBadge?: boolean;
forceCount?: boolean;
oobData?: object;
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");
}
}
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') {
this.setState({icon: this.calculateIcon()});
}
};
private onPresenceUpdate = () => {
if (this.isUnmounted) return;
let 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>;
}
}

View file

@ -15,43 +15,36 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import BaseAvatar from './BaseAvatar';
export default createReactClass({
displayName: 'GroupAvatar',
export interface IProps {
groupId?: string;
groupName?: string;
groupAvatarUrl?: string;
width?: number;
height?: number;
resizeMethod?: string;
onClick?: React.MouseEventHandler;
}
propTypes: {
groupId: PropTypes.string,
groupName: PropTypes.string,
groupAvatarUrl: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
onClick: PropTypes.func,
},
export default class GroupAvatar extends React.Component<IProps> {
public static defaultProps = {
width: 36,
height: 36,
resizeMethod: 'crop',
};
getDefaultProps: function() {
return {
width: 36,
height: 36,
resizeMethod: 'crop',
};
},
getGroupAvatarUrl: function() {
getGroupAvatarUrl() {
return MatrixClientPeg.get().mxcUrlToHttp(
this.props.groupAvatarUrl,
this.props.width,
this.props.height,
this.props.resizeMethod,
);
},
}
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
render() {
// extract the props we use from props so we can pass any others through
// should consider adding this as a global rule in js-sdk?
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
@ -65,5 +58,5 @@ export default createReactClass({
{...otherProps}
/>
);
},
});
}
}

View file

@ -16,55 +16,62 @@ 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";
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import BaseAvatar from "./BaseAvatar";
export default createReactClass({
displayName: 'MemberAvatar',
interface IProps {
// TODO: replace with correct type
member: any;
fallbackUserId: string;
width: number;
height: number;
resizeMethod: string;
// The onClick to give the avatar
onClick: React.MouseEventHandler;
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
viewUserOnClick: boolean;
title: string;
}
propTypes: {
member: PropTypes.object,
fallbackUserId: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
// The onClick to give the avatar
onClick: PropTypes.func,
// Whether the onClick of the avatar should be overriden to dispatch 'view_user'
viewUserOnClick: PropTypes.bool,
title: PropTypes.string,
},
interface IState {
name: string;
title: string;
imageUrl?: string;
}
getDefaultProps: function() {
return {
width: 40,
height: 40,
resizeMethod: 'crop',
viewUserOnClick: false,
};
},
export default class MemberAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
width: 40,
height: 40,
resizeMethod: 'crop',
viewUserOnClick: false,
};
getInitialState: function() {
return this._getState(this.props);
},
constructor(props: IProps) {
super(props);
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(nextProps) {
this.setState(this._getState(nextProps));
},
this.state = MemberAvatar.getState(props);
}
_getState: function(props) {
public static getDerivedStateFromProps(nextProps: IProps): IState {
return MemberAvatar.getState(nextProps);
}
private static getState(props: IProps): IState {
if (props.member && props.member.name) {
return {
name: props.member.name,
title: props.title || props.member.userId,
imageUrl: Avatar.avatarUrlForMember(props.member,
props.width,
props.height,
props.resizeMethod),
imageUrl: props.member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
false,
),
};
} else if (props.fallbackUserId) {
return {
@ -74,18 +81,16 @@ export default createReactClass({
} else {
console.error("MemberAvatar called somehow with null member or fallbackUserId");
}
},
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
}
render() {
let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props;
const userId = member ? member.userId : fallbackUserId;
if (viewUserOnClick) {
onClick = () => {
dis.dispatch({
action: 'view_user',
action: Action.ViewUser,
member: this.props.member,
});
};
@ -95,5 +100,5 @@ export default createReactClass({
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
idName={userId} url={this.state.imageUrl} onClick={onClick} />
);
},
});
}
}

View file

@ -53,7 +53,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 +105,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;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 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.
@ -15,23 +15,14 @@ limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const GroupsButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton className="mx_GroupsButton" action="toggle_my_groups"
label={_t("Communities")}
size={props.size}
tooltip={true}
/>
);
interface IProps {
}
const PulsedAvatar: React.FC<IProps> = (props) => {
return <div className="mx_PulsedAvatar">
{props.children}
</div>;
};
GroupsButton.propTypes = {
size: PropTypes.string,
};
export default GroupsButton;
export default PulsedAvatar;

View file

@ -13,90 +13,96 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import React from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as sdk from "../../../index";
import * as Avatar from '../../../Avatar';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
export default createReactClass({
displayName: 'RoomAvatar',
interface IProps {
// Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
propTypes: {
room: PropTypes.object,
oobData: PropTypes.object,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
viewAvatarOnClick: PropTypes.bool,
},
room?: Room;
// TODO: type when js-sdk has types
oobData?: any;
width?: number;
height?: number;
resizeMethod?: string;
viewAvatarOnClick?: boolean;
}
getDefaultProps: function() {
return {
width: 36,
height: 36,
resizeMethod: 'crop',
oobData: {},
interface IState {
urls: string[];
}
export default class RoomAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
width: 36,
height: 36,
resizeMethod: 'crop',
oobData: {},
};
constructor(props: IProps) {
super(props);
this.state = {
urls: RoomAvatar.getImageUrls(this.props),
};
},
}
getInitialState: function() {
return {
urls: this.getImageUrls(this.props),
};
},
componentDidMount: function() {
public componentDidMount() {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
}
componentWillUnmount: function() {
public componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this.onRoomStateEvents);
}
},
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
this.setState({
urls: this.getImageUrls(newProps),
});
},
public static getDerivedStateFromProps(nextProps: IProps): IState {
return {
urls: RoomAvatar.getImageUrls(nextProps),
};
}
onRoomStateEvents: function(ev) {
// TODO: type when js-sdk has types
private onRoomStateEvents = (ev: any) => {
if (!this.props.room ||
ev.getRoomId() !== this.props.room.roomId ||
ev.getType() !== 'm.room.avatar'
) return;
this.setState({
urls: this.getImageUrls(this.props),
urls: RoomAvatar.getImageUrls(this.props),
});
},
};
getImageUrls: function(props) {
private static getImageUrls(props: IProps): string[] {
return [
getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
// Default props don't play nicely with getDerivedStateFromProps
//props.oobData !== undefined ? props.oobData.avatarUrl : {},
props.oobData.avatarUrl,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
), // highest priority
this.getRoomAvatarUrl(props),
RoomAvatar.getRoomAvatarUrl(props),
].filter(function(url) {
return (url != null && url != "");
return (url !== null && url !== "");
});
},
}
getRoomAvatarUrl: function(props) {
private static getRoomAvatarUrl(props: IProps): string {
if (!props.room) return null;
return Avatar.avatarUrlForRoom(
@ -105,35 +111,32 @@ export default createReactClass({
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
);
},
}
onRoomAvatarClick: function() {
private onRoomAvatarClick = () => {
const avatarUrl = this.props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
null, null, null, false);
const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: avatarUrl,
name: this.props.room.name,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
},
};
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
public render() {
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
const roomName = room ? room.name : oobData.name;
return (
<BaseAvatar {...otherProps} name={roomName}
<BaseAvatar {...otherProps}
name={roomName}
idName={room ? room.roomId : null}
urls={this.state.urls}
onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null}
disabled={!this.state.urls[0]} />
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null}
/>
);
},
});
}
}

View file

@ -0,0 +1,124 @@
/*
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 {
ChevronFace,
ContextMenu,
IProps as IContextMenuProps,
MenuItem,
MenuItemCheckbox, MenuItemRadio,
} from "../../structures/ContextMenu";
interface IProps extends IContextMenuProps {
className?: string;
compact?: boolean;
}
interface IOptionListProps {
first?: boolean;
red?: boolean;
className?: string;
}
interface IOptionProps extends React.ComponentProps<typeof MenuItem> {
iconClassName: string;
}
interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
iconClassName: string;
}
interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> {
iconClassName: string;
}
export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
label,
iconClassName,
active,
className,
...props
}) => {
return <MenuItemRadio
{...props}
className={classNames(className, {
mx_IconizedContextMenu_active: active,
})}
active={active}
label={label}
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{label}</span>
{active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" />}
</MenuItemRadio>;
};
export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
label,
iconClassName,
active,
className,
...props
}) => {
return <MenuItemCheckbox
{...props}
className={classNames(className, {
mx_IconizedContextMenu_active: active,
})}
active={active}
label={label}
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{label}</span>
{active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" />}
</MenuItemCheckbox>;
};
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({label, iconClassName, ...props}) => {
return <MenuItem {...props} label={label}>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{label}</span>
</MenuItem>;
};
export const IconizedContextMenuOptionList: React.FC<IOptionListProps> = ({first, red, className, children}) => {
const classes = classNames("mx_IconizedContextMenu_optionList", className, {
mx_IconizedContextMenu_optionList_notFirst: !first,
mx_IconizedContextMenu_optionList_red: red,
});
return <div className={classes}>
{children}
</div>;
};
const IconizedContextMenu: React.FC<IProps> = ({className, children, compact, ...props}) => {
const classes = classNames("mx_IconizedContextMenu", className, {
mx_IconizedContextMenu_compact: compact,
});
return <ContextMenu chevronFace={ChevronFace.None} {...props}>
<div className={classes}>
{ children }
</div>
</ContextMenu>;
};
export default IconizedContextMenu;

View file

@ -23,7 +23,7 @@ import createReactClass from 'create-react-class';
import {EventStatus} from 'matrix-js-sdk';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
@ -81,7 +81,7 @@ export default createReactClass({
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.isFeatureEnabled("feature_pinning")) canPin = false;
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
this.setState({canRedact, canPin});
},
@ -116,11 +116,6 @@ export default createReactClass({
this.closeMenu();
},
e2eInfoClicked: function() {
this.props.e2eInfoCallback();
this.closeMenu();
},
onReportEventClick: function() {
const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog");
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
@ -130,22 +125,24 @@ export default createReactClass({
},
onViewSourceClick: function() {
const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent;
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
content: this.props.mxEvent.event,
roomId: ev.getRoomId(),
eventId: ev.getId(),
content: ev.event,
}, 'mx_Dialog_viewsource');
this.closeMenu();
},
onViewClearSourceClick: function() {
const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent;
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
roomId: ev.getRoomId(),
eventId: ev.getId(),
// FIXME: _clearEvent is private
content: this.props.mxEvent._clearEvent,
content: ev._clearEvent,
}, 'mx_Dialog_viewsource');
this.closeMenu();
},
@ -463,15 +460,6 @@ export default createReactClass({
);
}
let e2eInfo;
if (this.props.e2eInfoCallback) {
e2eInfo = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.e2eInfoClicked}>
{ _t('End-to-end encryption information') }
</MenuItem>
);
}
let reportEventButton;
if (mxEvent.getSender() !== me) {
reportEventButton = (
@ -498,7 +486,6 @@ export default createReactClass({
{ quoteButton }
{ externalURLButton }
{ collapseReplyThread }
{ e2eInfo }
{ reportEventButton }
</div>
);

View file

@ -24,7 +24,7 @@ import classNames from 'classnames';
import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import DMRoomMap from '../../../utils/DMRoomMap';
import * as Rooms from '../../../Rooms';
import * as RoomNotifs from '../../../RoomNotifs';

View file

@ -18,9 +18,8 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import TagOrderActions from '../../../actions/TagOrderActions';
import * as sdk from '../../../index';
import {MenuItem} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -54,21 +53,12 @@ export default class TagTileContextMenu extends React.Component {
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return <div>
<MenuItem className="mx_TagTileContextMenu_item" onClick={this._onViewCommunityClick}>
<TintableSvg
className="mx_TagTileContextMenu_item_icon"
src={require("../../../../res/img/icons-groups.svg")}
width="15"
height="15"
/>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') }
</MenuItem>
<hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item" onClick={this._onRemoveClick}>
<img className="mx_TagTileContextMenu_item_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" alt="" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t('Hide') }
</MenuItem>
</div>;

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import LogoutDialog from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
@ -27,6 +27,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {MenuItem} from "../../structures/ContextMenu";
import * as sdk from "../../../index";
import {getHomePageUrl} from "../../../utils/pages";
import {Action} from "../../../dispatcher/actions";
export default class TopLeftMenu extends React.Component {
static propTypes = {
@ -134,7 +135,7 @@ export default class TopLeftMenu extends React.Component {
}
openSettings() {
dis.dispatch({action: 'view_user_settings'});
dis.fire(Action.ViewUserSettings);
this.closeMenu();
}

View file

@ -1,44 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
export default createReactClass({
displayName: 'CreateRoomButton',
propTypes: {
onCreateRoom: PropTypes.func,
},
getDefaultProps: function() {
return {
onCreateRoom: function() {},
};
},
onClick: function() {
this.props.onCreateRoom();
},
render: function() {
return (
<button className="mx_CreateRoomButton" onClick={this.onClick}>{ _t("Create Room") }</button>
);
},
});

View file

@ -98,7 +98,7 @@ export default createReactClass({
render: function() {
return (
<input type="text" className="mx_RoomAlias" placeholder={_t("Alias (optional)")}
<input type="text" className="mx_RoomAlias" placeholder={_t("Address (optional)")}
onChange={this.onValueChanged} onFocus={this.onFocus} onBlur={this.onBlur}
value={this.props.alias} />
);

View file

@ -24,7 +24,7 @@ import createReactClass from 'create-react-class';
import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email';
@ -33,6 +33,7 @@ import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../
import { abbreviateUrl } from '../../../utils/UrlUtils';
import {sleep} from "../../../utils/promise";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -615,7 +616,7 @@ export default createReactClass({
onManageSettingsClick(e) {
e.preventDefault();
dis.dispatch({ action: 'view_user_settings' });
dis.fire(Action.ViewUserSettings);
this.onCancel();
},

View file

@ -19,8 +19,8 @@ import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {SettingLevel} from "../../../settings/SettingsStore";
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
export default createReactClass({
propTypes: {

View file

@ -75,8 +75,12 @@ export default createReactClass({
// If provided, this is used to add a aria-describedby attribute
contentId: PropTypes.string,
// optional additional class for the title element
titleClass: PropTypes.string,
// optional additional class for the title element (basically anything that can be passed to classnames)
titleClass: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
PropTypes.arrayOf(PropTypes.string),
]),
},
getDefaultProps: function() {
@ -144,6 +148,7 @@ export default createReactClass({
>
<div className={classNames('mx_Dialog_header', {
'mx_Dialog_headerWithButton': !!this.props.headerButton,
'mx_Dialog_headerWithCancel': !!cancelButton,
})}>
<div className={classNames('mx_Dialog_title', this.props.titleClass)} id='mx_BaseDialog_title'>
{headerImage}

View file

@ -23,7 +23,8 @@ import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import sendBugReport from '../../../rageshake/submit-rageshake';
import sendBugReport, {downloadBugReport} from '../../../rageshake/submit-rageshake';
import AccessibleButton from "../elements/AccessibleButton";
export default class BugReportDialog extends React.Component {
constructor(props) {
@ -35,6 +36,8 @@ export default class BugReportDialog extends React.Component {
issueUrl: "",
text: "",
progress: null,
downloadBusy: false,
downloadProgress: null,
};
this._unmounted = false;
this._onSubmit = this._onSubmit.bind(this);
@ -43,6 +46,7 @@ export default class BugReportDialog extends React.Component {
this._onIssueUrlChange = this._onIssueUrlChange.bind(this);
this._onSendLogsChange = this._onSendLogsChange.bind(this);
this._sendProgressCallback = this._sendProgressCallback.bind(this);
this._downloadProgressCallback = this._downloadProgressCallback.bind(this);
}
componentWillUnmount() {
@ -95,6 +99,31 @@ export default class BugReportDialog extends React.Component {
});
}
_onDownload = async (ev) => {
this.setState({ downloadBusy: true });
this._downloadProgressCallback(_t("Preparing to download logs"));
try {
await downloadBugReport({
sendLogs: true,
progressCallback: this._downloadProgressCallback,
label: this.props.label,
});
this.setState({
downloadBusy: false,
downloadProgress: null,
});
} catch (err) {
if (!this._unmounted) {
this.setState({
downloadBusy: false,
downloadProgress: _t("Failed to send logs: ") + `${err.message}`,
});
}
}
};
_onTextChange(ev) {
this.setState({ text: ev.target.value });
}
@ -114,6 +143,13 @@ export default class BugReportDialog extends React.Component {
this.setState({progress: progress});
}
_downloadProgressCallback(downloadProgress) {
if (this._unmounted) {
return;
}
this.setState({ downloadProgress });
}
render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -166,20 +202,28 @@ export default class BugReportDialog extends React.Component {
{
a: (sub) => <a
target="_blank"
href="https://github.com/vector-im/riot-web/issues/new"
href="https://github.com/vector-im/element-web/issues/new"
>
{ sub }
</a>,
},
) }
</b></p>
<div className="mx_BugReportDialog_download">
<AccessibleButton onClick={this._onDownload} kind="link" disabled={this.state.downloadBusy}>
{ _t("Download logs") }
</AccessibleButton>
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
</div>
<Field
type="text"
className="mx_BugReportDialog_field_input"
label={_t("GitHub issue")}
onChange={this._onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/riot-web/issues/..."
placeholder="https://github.com/vector-im/element-web/issues/..."
/>
<Field
className="mx_BugReportDialog_field_input"

View file

@ -21,7 +21,7 @@ import * as sdk from '../../../index';
import request from 'browser-request';
import { _t } from '../../../languageHandler';
const REPOS = ['vector-im/riot-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk'];
const REPOS = ['vector-im/element-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk'];
export default class ChangelogDialog extends React.Component {
constructor(props) {

View file

@ -18,7 +18,7 @@ import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';

View file

@ -24,19 +24,20 @@ import withValidation from '../elements/Validation';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard";
import SettingsStore from "../../../settings/SettingsStore";
import {privateShouldBeEncrypted} from "../../../createRoom";
export default createReactClass({
displayName: 'CreateRoomDialog',
propTypes: {
onFinished: PropTypes.func.isRequired,
defaultPublic: PropTypes.bool,
},
getInitialState() {
const config = SdkConfig.get();
return {
isPublic: false,
isEncrypted: true,
isPublic: this.props.defaultPublic || false,
isEncrypted: privateShouldBeEncrypted(),
name: "",
topic: "",
alias: "",
@ -65,7 +66,7 @@ export default createReactClass({
createOpts.creation_content = {'m.federate': false};
}
if (!this.state.isPublic && SettingsStore.getValue("feature_cross_signing")) {
if (!this.state.isPublic) {
opts.encryption = this.state.isEncrypted;
}
@ -180,7 +181,7 @@ export default createReactClass({
let publicPrivateLabel;
let aliasField;
if (this.state.isPublic) {
publicPrivateLabel = (<p>{_t("Set a room alias to easily share your room with other people.")}</p>);
publicPrivateLabel = (<p>{_t("Set a room address to easily share your room with other people.")}</p>);
const domain = MatrixClientPeg.get().getDomain();
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
@ -192,7 +193,14 @@ export default createReactClass({
}
let e2eeSection;
if (!this.state.isPublic && SettingsStore.getValue("feature_cross_signing")) {
if (!this.state.isPublic) {
let microcopy;
if (privateShouldBeEncrypted()) {
microcopy = _t("You cant disable this later. Bridges & most bots wont work yet.");
} else {
microcopy = _t("Your server admin has disabled end-to-end encryption by default " +
"in private rooms & Direct Messages.");
}
e2eeSection = <React.Fragment>
<LabelledToggleSwitch
label={ _t("Enable end-to-end encryption")}
@ -200,7 +208,7 @@ export default createReactClass({
value={this.state.isEncrypted}
className='mx_CreateRoomDialog_e2eSwitch' // for end-to-end tests
/>
<p>{ _t("You cant disable this later. Bridges & most bots wont work yet.") }</p>
<p>{ microcopy }</p>
</React.Fragment>;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,11 +17,14 @@ limitations under the License.
import React from 'react';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
export default (props) => {
const brand = SdkConfig.get().brand;
const _onLogoutClicked = () => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Logout e2e db too new', '', QuestionDialog, {
@ -28,7 +32,8 @@ export default (props) => {
description: _t(
"To avoid losing your chat history, you must export your room keys " +
"before logging out. You will need to go back to the newer version of " +
"Riot to do this",
"%(brand)s to do this",
{ brand },
),
button: _t("Sign out"),
focus: false,
@ -42,10 +47,11 @@ export default (props) => {
};
const description =
_t("You've previously used a newer version of Riot on %(host)s. " +
_t(
"You've previously used a newer version of %(brand)s with this session. " +
"To use this version again with end to end encryption, you will " +
"need to sign out and back in again. ",
{host: props.host},
"need to sign out and back in again.",
{ brand },
);
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');

View file

@ -25,6 +25,7 @@ import * as Lifecycle from '../../../Lifecycle';
import { _t } from '../../../languageHandler';
import InteractiveAuth, {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth";
import {DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents";
import StyledCheckbox from "../elements/StyledCheckbox";
export default class DeactivateAccountDialog extends React.Component {
constructor(props) {
@ -34,6 +35,7 @@ export default class DeactivateAccountDialog extends React.Component {
shouldErase: false,
errStr: null,
authData: null, // for UIA
authEnabled: true, // see usages for information
// A few strings that are passed to InteractiveAuth for design or are displayed
// next to the InteractiveAuth component.
@ -42,21 +44,7 @@ export default class DeactivateAccountDialog extends React.Component {
continueKind: null,
};
MatrixClientPeg.get().deactivateAccount(null, false).then(r => {
// If we got here, oops. The server didn't require any auth.
// Our application lifecycle will catch the error and do the logout bits.
// We'll try to log something in an vain attempt to record what happened (storage
// is also obliterated on logout).
console.warn("User's account got deactivated without confirmation: Server had no auth");
this.setState({errStr: _t("Server did not require any authentication")});
}).catch(e => {
if (e && e.httpStatus === 401 && e.data) {
// Valid UIA response
this.setState({authData: e.data});
} else {
this.setState({errStr: _t("Server did not return valid authentication information.")});
}
});
this._initAuth(/* shouldErase= */false);
}
_onStagePhaseChange = (stage, phase) => {
@ -124,13 +112,40 @@ export default class DeactivateAccountDialog extends React.Component {
_onEraseFieldChange = (ev) => {
this.setState({
shouldErase: ev.target.checked,
// Disable the auth form because we're going to have to reinitialize the auth
// information. We do this because we can't modify the parameters in the UIA
// session, and the user will have selected something which changes the request.
// Therefore, we throw away the last auth session and try a new one.
authEnabled: false,
});
// As mentioned above, set up for auth again to get updated UIA session info
this._initAuth(/* shouldErase= */ev.target.checked);
};
_onCancel() {
this.props.onFinished(false);
}
_initAuth(shouldErase) {
MatrixClientPeg.get().deactivateAccount(null, shouldErase).then(r => {
// If we got here, oops. The server didn't require any auth.
// Our application lifecycle will catch the error and do the logout bits.
// We'll try to log something in an vain attempt to record what happened (storage
// is also obliterated on logout).
console.warn("User's account got deactivated without confirmation: Server had no auth");
this.setState({errStr: _t("Server did not require any authentication")});
}).catch(e => {
if (e && e.httpStatus === 401 && e.data) {
// Valid UIA response
this.setState({authData: e.data, authEnabled: true});
} else {
this.setState({errStr: _t("Server did not return valid authentication information.")});
}
});
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -142,7 +157,7 @@ export default class DeactivateAccountDialog extends React.Component {
}
let auth = <div>{_t("Loading...")}</div>;
if (this.state.authData) {
if (this.state.authData && this.state.authEnabled) {
auth = (
<div>
{this.state.bodyText}
@ -195,21 +210,18 @@ export default class DeactivateAccountDialog extends React.Component {
<div className="mx_DeactivateAccountDialog_input_section">
<p>
<label htmlFor="mx_DeactivateAccountDialog_erase_account_input">
<input
id="mx_DeactivateAccountDialog_erase_account_input"
type="checkbox"
checked={this.state.shouldErase}
onChange={this._onEraseFieldChange}
/>
{ _t(
<StyledCheckbox
checked={this.state.shouldErase}
onChange={this._onEraseFieldChange}
>
{_t(
"Please forget all messages I have sent when my account is deactivated " +
"(<b>Warning:</b> this will cause future users to see an incomplete view " +
"of conversations)",
{},
{ b: (sub) => <b>{ sub }</b> },
) }
</label>
)}
</StyledCheckbox>
</p>
{error}

View file

@ -1,377 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 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 PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import { _t } from '../../../languageHandler';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import {ensureDMExists} from "../../../createRoom";
import dis from "../../../dispatcher";
import SettingsStore from '../../../settings/SettingsStore';
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
import VerificationQREmojiOptions from "../verification/VerificationQREmojiOptions";
const MODE_LEGACY = 'legacy';
const MODE_SAS = 'sas';
const PHASE_START = 0;
const PHASE_WAIT_FOR_PARTNER_TO_ACCEPT = 1;
const PHASE_PICK_VERIFICATION_OPTION = 2;
const PHASE_SHOW_SAS = 3;
const PHASE_WAIT_FOR_PARTNER_TO_CONFIRM = 4;
const PHASE_VERIFIED = 5;
const PHASE_CANCELLED = 6;
export default class DeviceVerifyDialog extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
device: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
};
constructor() {
super();
this._verifier = null;
this._showSasEvent = null;
this._request = null;
this.state = {
phase: PHASE_START,
mode: MODE_SAS,
sasVerified: false,
};
}
componentWillUnmount() {
if (this._verifier) {
this._verifier.removeListener('show_sas', this._onVerifierShowSas);
this._verifier.cancel('User cancel');
}
}
_onSwitchToLegacyClick = () => {
if (this._verifier) {
this._verifier.removeListener('show_sas', this._onVerifierShowSas);
this._verifier.cancel('User cancel');
this._verifier = null;
}
this.setState({mode: MODE_LEGACY});
}
_onSwitchToSasClick = () => {
this.setState({mode: MODE_SAS});
}
_onCancelClick = () => {
this.props.onFinished(false);
}
_onUseSasClick = async () => {
try {
this._verifier = this._request.beginKeyVerification(verificationMethods.SAS);
this._verifier.on('show_sas', this._onVerifierShowSas);
// throws upon cancellation
await this._verifier.verify();
this.setState({phase: PHASE_VERIFIED});
this._verifier.removeListener('show_sas', this._onVerifierShowSas);
this._verifier = null;
} catch (e) {
console.log("Verification failed", e);
this.setState({
phase: PHASE_CANCELLED,
});
this._verifier = null;
this._request = null;
}
};
_onLegacyFinished = (confirm) => {
if (confirm) {
MatrixClientPeg.get().setDeviceVerified(
this.props.userId, this.props.device.deviceId, true,
);
}
this.props.onFinished(confirm);
}
_onSasRequestClick = async () => {
this.setState({
phase: PHASE_WAIT_FOR_PARTNER_TO_ACCEPT,
});
const client = MatrixClientPeg.get();
const verifyingOwnDevice = this.props.userId === client.getUserId();
try {
if (!verifyingOwnDevice && SettingsStore.getValue("feature_cross_signing")) {
const roomId = await ensureDMExistsAndOpen(this.props.userId);
// throws upon cancellation before having started
const request = await client.requestVerificationDM(
this.props.userId, roomId,
);
await request.waitFor(r => r.ready || r.started);
if (request.ready) {
this._verifier = request.beginKeyVerification(verificationMethods.SAS);
} else {
this._verifier = request.verifier;
}
} else if (verifyingOwnDevice && SettingsStore.getValue("feature_cross_signing")) {
this._request = await client.requestVerification(this.props.userId, [
verificationMethods.SAS,
SHOW_QR_CODE_METHOD,
verificationMethods.RECIPROCATE_QR_CODE,
]);
await this._request.waitFor(r => r.ready || r.started);
this.setState({phase: PHASE_PICK_VERIFICATION_OPTION});
} else {
this._verifier = client.beginKeyVerification(
verificationMethods.SAS, this.props.userId, this.props.device.deviceId,
);
}
if (!this._verifier) return;
this._verifier.on('show_sas', this._onVerifierShowSas);
// throws upon cancellation
await this._verifier.verify();
this.setState({phase: PHASE_VERIFIED});
this._verifier.removeListener('show_sas', this._onVerifierShowSas);
this._verifier = null;
} catch (e) {
console.log("Verification failed", e);
this.setState({
phase: PHASE_CANCELLED,
});
this._verifier = null;
}
}
_onSasMatchesClick = () => {
this._showSasEvent.confirm();
this.setState({
phase: PHASE_WAIT_FOR_PARTNER_TO_CONFIRM,
});
}
_onVerifiedDoneClick = () => {
this.props.onFinished(true);
}
_onVerifierShowSas = (e) => {
this._showSasEvent = e;
this.setState({
phase: PHASE_SHOW_SAS,
});
}
_renderSasVerification() {
let body;
switch (this.state.phase) {
case PHASE_START:
body = this._renderVerificationPhaseStart();
break;
case PHASE_WAIT_FOR_PARTNER_TO_ACCEPT:
body = this._renderVerificationPhaseWaitAccept();
break;
case PHASE_PICK_VERIFICATION_OPTION:
body = this._renderVerificationPhasePick();
break;
case PHASE_SHOW_SAS:
body = this._renderSasVerificationPhaseShowSas();
break;
case PHASE_WAIT_FOR_PARTNER_TO_CONFIRM:
body = this._renderSasVerificationPhaseWaitForPartnerToConfirm();
break;
case PHASE_VERIFIED:
body = this._renderVerificationPhaseVerified();
break;
case PHASE_CANCELLED:
body = this._renderVerificationPhaseCancelled();
break;
}
const BaseDialog = sdk.getComponent("dialogs.BaseDialog");
return (
<BaseDialog
title={_t("Verify session")}
onFinished={this._onCancelClick}
>
{body}
</BaseDialog>
);
}
_renderVerificationPhaseStart() {
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<div>
<AccessibleButton
element="span" className="mx_linkButton" onClick={this._onSwitchToLegacyClick}
>
{_t("Use Legacy Verification (for older clients)")}
</AccessibleButton>
<p>
{ _t("Verify by comparing a short text string.") }
</p>
<p>
{_t("To be secure, do this in person or use a trusted way to communicate.")}
</p>
<DialogButtons
primaryButton={_t('Begin Verifying')}
hasCancel={true}
onPrimaryButtonClick={this._onSasRequestClick}
onCancel={this._onCancelClick}
/>
</div>
);
}
_renderVerificationPhaseWaitAccept() {
const Spinner = sdk.getComponent("views.elements.Spinner");
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
return (
<div>
<Spinner />
<p>{_t("Waiting for partner to accept...")}</p>
<p>{_t(
"Nothing appearing? Not all clients support interactive verification yet. " +
"<button>Use legacy verification</button>.",
{}, {button: sub => <AccessibleButton element='span' className="mx_linkButton"
onClick={this._onSwitchToLegacyClick}
>
{sub}
</AccessibleButton>},
)}</p>
</div>
);
}
_renderVerificationPhasePick() {
return <VerificationQREmojiOptions
request={this._request}
onCancel={this._onCancelClick}
onStartEmoji={this._onUseSasClick}
/>;
}
_renderSasVerificationPhaseShowSas() {
const VerificationShowSas = sdk.getComponent('views.verification.VerificationShowSas');
return <VerificationShowSas
sas={this._showSasEvent.sas}
onCancel={this._onCancelClick}
onDone={this._onSasMatchesClick}
isSelf={MatrixClientPeg.get().getUserId() === this.props.userId}
onStartEmoji={this._onUseSasClick}
inDialog={true}
/>;
}
_renderSasVerificationPhaseWaitForPartnerToConfirm() {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <div>
<Spinner />
<p>{_t(
"Waiting for %(userId)s to confirm...", {userId: this.props.userId},
)}</p>
</div>;
}
_renderVerificationPhaseVerified() {
const VerificationComplete = sdk.getComponent('views.verification.VerificationComplete');
return <VerificationComplete onDone={this._onVerifiedDoneClick} />;
}
_renderVerificationPhaseCancelled() {
const VerificationCancelled = sdk.getComponent('views.verification.VerificationCancelled');
return <VerificationCancelled onDone={this._onCancelClick} />;
}
_renderLegacyVerification() {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
let text;
if (MatrixClientPeg.get().getUserId() === this.props.userId) {
text = _t("To verify that this session can be trusted, please check that the key you see " +
"in User Settings on that device matches the key below:");
} else {
text = _t("To verify that this session can be trusted, please contact its owner using some other " +
"means (e.g. in person or a phone call) and ask them whether the key they see in their User Settings " +
"for this session matches the key below:");
}
const key = FormattingUtils.formatCryptoKey(this.props.device.getFingerprint());
const body = (
<div>
<AccessibleButton
element="span" className="mx_linkButton" onClick={this._onSwitchToSasClick}
>
{_t("Use two-way text verification")}
</AccessibleButton>
<p>
{ text }
</p>
<div className="mx_DeviceVerifyDialog_cryptoSection">
<ul>
<li><label>{ _t("Session name") }:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>{ _t("Session ID") }:</label> <span><code>{ this.props.device.deviceId }</code></span></li>
<li><label>{ _t("Session key") }:</label> <span><code><b>{ key }</b></code></span></li>
</ul>
</div>
<p>
{ _t("If it matches, press the verify button below. " +
"If it doesn't, then someone else is intercepting this session " +
"and you probably want to press the blacklist button instead.") }
</p>
</div>
);
return (
<QuestionDialog
title={_t("Verify session")}
description={body}
button={_t("I verify that the keys match")}
onFinished={this._onLegacyFinished}
/>
);
}
render() {
if (this.state.mode === MODE_LEGACY) {
return this._renderLegacyVerification();
} else {
return <div>
{this._renderSasVerification()}
</div>;
}
}
}
async function ensureDMExistsAndOpen(userId) {
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
// don't use andView and spinner in createRoom, together, they cause this dialog to close and reopen,
// we causes us to loose the verifier and restart, and we end up having two verification requests
dis.dispatch({
action: 'view_room',
room_id: roomId,
should_peek: false,
});
return roomId;
}

View file

@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler';
import { Room } from "matrix-js-sdk";
import { Room, MatrixEvent } from "matrix-js-sdk";
import Field from "../elements/Field";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
@ -327,6 +327,8 @@ class RoomStateExplorer extends React.PureComponent {
static contextType = MatrixClientContext;
roomStateEvents: Map<string, Map<string, MatrixEvent>>;
constructor(props) {
super(props);
@ -412,30 +414,26 @@ class RoomStateExplorer extends React.PureComponent {
if (this.state.eventType === null) {
list = <FilteredList query={this.state.queryEventType} onChange={this.onQueryEventType}>
{
Object.keys(this.roomStateEvents).map((evType) => {
const stateGroup = this.roomStateEvents[evType];
const stateKeys = Object.keys(stateGroup);
Array.from(this.roomStateEvents.entries()).map(([eventType, allStateKeys]) => {
let onClickFn;
if (stateKeys.length === 1 && stateKeys[0] === '') {
onClickFn = this.onViewSourceClick(stateGroup[stateKeys[0]]);
if (allStateKeys.size === 1 && allStateKeys.has("")) {
onClickFn = this.onViewSourceClick(allStateKeys.get(""));
} else {
onClickFn = this.browseEventType(evType);
onClickFn = this.browseEventType(eventType);
}
return <button className={classes} key={evType} onClick={onClickFn}>
{ evType }
return <button className={classes} key={eventType} onClick={onClickFn}>
{eventType}
</button>;
})
}
</FilteredList>;
} else {
const stateGroup = this.roomStateEvents[this.state.eventType];
const stateGroup = this.roomStateEvents.get(this.state.eventType);
list = <FilteredList query={this.state.queryStateKey} onChange={this.onQueryStateKey}>
{
Object.keys(stateGroup).map((stateKey) => {
const ev = stateGroup[stateKey];
Array.from(stateGroup.entries()).map(([stateKey, ev]) => {
return <button className={classes} key={stateKey} onClick={this.onViewSourceClick(ev)}>
{ stateKey }
</button>;

View file

@ -18,7 +18,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import * as sdk from "../../../index";
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import {Action} from "../../../dispatcher/actions";
export default class IntegrationsDisabledDialog extends React.Component {
static propTypes = {
@ -31,7 +32,7 @@ export default class IntegrationsDisabledDialog extends React.Component {
_onOpenSettingsClick = () => {
this.props.onFinished();
dis.dispatch({action: "view_user_settings"});
dis.fire(Action.ViewUserSettings);
};
render() {

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import * as sdk from "../../../index";
export default class IntegrationsImpossibleDialog extends React.Component {
@ -29,6 +30,7 @@ export default class IntegrationsImpossibleDialog extends React.Component {
};
render() {
const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -39,8 +41,9 @@ export default class IntegrationsImpossibleDialog extends React.Component {
<div className='mx_IntegrationsImpossibleDialog_content'>
<p>
{_t(
"Your Riot doesn't allow you to use an Integration Manager to do this. " +
"Your %(brand)s doesn't allow you to use an Integration Manager to do this. " +
"Please contact an admin.",
{ brand },
)}
</p>
</div>

View file

@ -25,6 +25,7 @@ import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth";
import {SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents";
export default createReactClass({
displayName: 'InteractiveAuthDialog',
@ -66,6 +67,8 @@ export default createReactClass({
// }
// }
// }
//
// Default is defined in _getDefaultDialogAesthetics()
aestheticsForStagePhases: PropTypes.object,
},
@ -79,6 +82,28 @@ export default createReactClass({
};
},
_getDefaultDialogAesthetics: function() {
const ssoAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("To continue, use Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm to continue"),
body: _t("Click the button below to confirm your identity."),
continueText: _t("Confirm"),
continueKind: "primary",
},
};
return {
[SSOAuthEntry.LOGIN_TYPE]: ssoAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: ssoAesthetics,
};
},
_onAuthFinished: function(success, result) {
if (success) {
this.props.onFinished(true, result);
@ -113,9 +138,10 @@ export default createReactClass({
let body = this.state.authError ? null : this.props.body;
let continueText = null;
let continueKind = null;
if (!this.state.authError && this.props.aestheticsForStagePhases) {
if (this.props.aestheticsForStagePhases[this.state.uiaStage]) {
const aesthetics = this.props.aestheticsForStagePhases[this.state.uiaStage][this.state.uiaStagePhase];
const dialogAesthetics = this.props.aestheticsForStagePhases || this._getDefaultDialogAesthetics();
if (!this.state.authError && dialogAesthetics) {
if (dialogAesthetics[this.state.uiaStage]) {
const aesthetics = dialogAesthetics[this.state.uiaStage][this.state.uiaStagePhase];
if (aesthetics && aesthetics.title) title = aesthetics.title;
if (aesthetics && aesthetics.body) body = aesthetics.body;
if (aesthetics && aesthetics.continueText) continueText = aesthetics.continueText;

View file

@ -27,15 +27,19 @@ import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import * as Email from "../../../email";
import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils";
import {abbreviateUrl} from "../../../utils/UrlUtils";
import dis from "../../../dispatcher";
import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite";
import SettingsStore from '../../../settings/SettingsStore';
import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
import {DefaultTagID} from "../../../stores/room-list/models";
import RoomListStore from "../../../stores/room-list/RoomListStore";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
@ -343,10 +347,9 @@ export default class InviteDialog extends React.PureComponent {
_buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as TAG_DM so we don't miss anything. Sometimes the
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
const taggedRooms = RoomListStore.getRoomLists();
const dmTaggedRooms = taggedRooms[TAG_DM];
const dmTaggedRooms = RoomListStore.instance.orderedLists[DefaultTagID.DM] || [];
const myUserId = MatrixClientPeg.get().getUserId();
for (const dmRoom of dmTaggedRooms) {
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
@ -574,7 +577,7 @@ export default class InviteDialog extends React.PureComponent {
const createRoomOptions = {inlineErrors: true};
if (SettingsStore.getValue("feature_cross_signing")) {
if (privateShouldBeEncrypted()) {
// Check whether all users have uploaded device keys before.
// If so, enable encryption in the new room.
const has3PidMembers = targets.some(t => t instanceof ThreepidMember);
@ -902,7 +905,7 @@ export default class InviteDialog extends React.PureComponent {
_onManageSettingsClick = (e) => {
e.preventDefault();
dis.dispatch({ action: 'view_user_settings' });
dis.fire(Action.ViewUserSettings);
this.props.onFinished();
};
@ -1070,9 +1073,8 @@ export default class InviteDialog extends React.PureComponent {
let buttonText;
let goButtonFn;
const userId = MatrixClientPeg.get().getUserId();
if (this.props.kind === KIND_DM) {
const userId = MatrixClientPeg.get().getUserId();
title = _t("Direct Messages");
helpText = _t(
"Start a conversation with someone using their name, username (like <userId/>) or email address.",
@ -1086,9 +1088,11 @@ export default class InviteDialog extends React.PureComponent {
} else { // KIND_INVITE
title = _t("Invite to this room");
helpText = _t(
"If you can't find someone, ask them for their username (e.g. @user:server.com) or " +
"<a>share this room</a>.", {},
"Invite someone using their name, username (like <userId/>), email address or <a>share this room</a>.",
{},
{
userId: () =>
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
a: (sub) =>
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
},

View file

@ -1,178 +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 Modal from '../../../Modal';
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';
// TODO: We can remove this once cross-signing is the only way.
// https://github.com/vector-im/riot-web/issues/11908
/**
* Dialog which asks the user whether they want to share their keys with
* an unverified device.
*
* onFinished is called with `true` if the key should be shared, `false` if it
* should not, and `undefined` if the dialog is cancelled. (In other words:
* truthy: do the key share. falsy: don't share the keys).
*/
export default createReactClass({
propTypes: {
matrixClient: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
deviceId: PropTypes.string.isRequired,
onFinished: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
deviceInfo: null,
wasNewDevice: false,
};
},
componentDidMount: function() {
this._unmounted = false;
const userId = this.props.userId;
const deviceId = this.props.deviceId;
// give the client a chance to refresh the device list
this.props.matrixClient.downloadKeys([userId], false).then((r) => {
if (this._unmounted) { return; }
const deviceInfo = r[userId][deviceId];
if (!deviceInfo) {
console.warn(`No details found for session ${userId}:${deviceId}`);
this.props.onFinished(false);
return;
}
const wasNewDevice = !deviceInfo.isKnown();
this.setState({
deviceInfo: deviceInfo,
wasNewDevice: wasNewDevice,
});
// if the device was new before, it's not any more.
if (wasNewDevice) {
this.props.matrixClient.setDeviceKnown(
userId,
deviceId,
true,
);
}
});
},
componentWillUnmount: function() {
this._unmounted = true;
},
_onVerifyClicked: function() {
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
console.log("KeyShareDialog: Starting verify dialog");
Modal.createTrackedDialog('Key Share', 'Starting dialog', DeviceVerifyDialog, {
userId: this.props.userId,
device: this.state.deviceInfo,
onFinished: (verified) => {
if (verified) {
// can automatically share the keys now.
this.props.onFinished(true);
}
},
}, null, /* priority = */ false, /* static = */ true);
},
_onShareClicked: function() {
console.log("KeyShareDialog: User clicked 'share'");
this.props.onFinished(true);
},
_onIgnoreClicked: function() {
console.log("KeyShareDialog: User clicked 'ignore'");
this.props.onFinished(false);
},
_renderContent: function() {
const displayName = this.state.deviceInfo.getDisplayName() ||
this.state.deviceInfo.deviceId;
let text;
if (this.state.wasNewDevice) {
text = _td("You added a new session '%(displayName)s', which is"
+ " requesting encryption keys.");
} else {
text = _td("Your unverified session '%(displayName)s' is requesting"
+ " encryption keys.");
}
text = _t(text, {displayName: displayName});
return (
<div id='mx_Dialog_content'>
<p>{ text }</p>
<div className="mx_Dialog_buttons">
<button onClick={this._onVerifyClicked} autoFocus="true">
{ _t('Start verification') }
</button>
<button onClick={this._onShareClicked}>
{ _t('Share without verifying') }
</button>
<button onClick={this._onIgnoreClicked}>
{ _t('Ignore request') }
</button>
</div>
</div>
);
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Spinner = sdk.getComponent('views.elements.Spinner');
let content;
if (this.state.deviceInfo) {
content = this._renderContent();
} else {
content = (
<div id='mx_Dialog_content'>
<p>{ _t('Loading session info...') }</p>
<Spinner />
</div>
);
}
return (
<BaseDialog className='mx_KeyShareRequestDialog'
onFinished={this.props.onFinished}
title={_t('Encryption key request')}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>
);
},
});

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, {useState, useCallback, useRef} from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default function KeySignatureUploadFailedDialog({
failures,
@ -65,9 +66,10 @@ export default function KeySignatureUploadFailedDialog({
let body;
if (!success && !cancelled && continuation && retry > 0) {
const reason = causes.get(source) || defaultCause;
const brand = SdkConfig.get().brand;
body = (<div>
<p>{_t("Riot encountered an error during upload of:")}</p>
<p>{_t("%(brand)s encountered an error during upload of:", { brand })}</p>
<p>{reason}</p>
{retrying && <Spinner />}
<pre>{JSON.stringify(failures, null, 2)}</pre>

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,17 +18,28 @@ limitations under the License.
import React from 'react';
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default (props) => {
const description1 =
_t("You've previously used Riot on %(host)s with lazy loading of members enabled. " +
"In this version lazy loading is disabled. " +
"As the local cache is not compatible between these two settings, " +
"Riot needs to resync your account.",
{host: props.host});
const description2 = _t("If the other version of Riot is still open in another tab, " +
"please close it as using Riot on the same host with both " +
"lazy loading enabled and disabled simultaneously will cause issues.");
const brand = SdkConfig.get().brand;
const description1 = _t(
"You've previously used %(brand)s on %(host)s with lazy loading of members enabled. " +
"In this version lazy loading is disabled. " +
"As the local cache is not compatible between these two settings, " +
"%(brand)s needs to resync your account.",
{
brand,
host: props.host,
},
);
const description2 = _t(
"If the other version of %(brand)s is still open in another tab, " +
"please close it as using %(brand)s on the same host with both " +
"lazy loading enabled and disabled simultaneously will cause issues.",
{
brand,
},
);
return (<QuestionDialog
hasCancelButton={false}

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,15 +18,21 @@ limitations under the License.
import React from 'react';
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default (props) => {
const brand = SdkConfig.get().brand;
const description =
_t("Riot now uses 3-5x less memory, by only loading information about other users"
+ " when needed. Please wait whilst we resynchronise with the server!");
_t(
"%(brand)s now uses 3-5x less memory, by only loading information " +
"about other users when needed. Please wait whilst we resynchronise " +
"with the server!",
{ brand },
);
return (<QuestionDialog
hasCancelButton={false}
title={_t("Updating Riot")}
title={_t("Updating %(brand)s", { brand })}
description={<div>{description}</div>}
button={_t("OK")}
onFinished={props.onFinished}

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';

View file

@ -19,9 +19,9 @@ import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
export default (props) => {
const existingIssuesUrl = "https://github.com/vector-im/riot-web/issues" +
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
const description1 =
_t("If you run into any bugs or have feedback you'd like to share, " +

View file

@ -27,9 +27,16 @@ import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsT
import BridgeSettingsTab from "../settings/tabs/room/BridgeSettingsTab";
import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB";
export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB";
export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB";
export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB";
export default class RoomSettingsDialog extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
@ -56,28 +63,33 @@ export default class RoomSettingsDialog extends React.Component {
const tabs = [];
tabs.push(new Tab(
ROOM_GENERAL_TAB,
_td("General"),
"mx_RoomSettingsDialog_settingsIcon",
<GeneralRoomSettingsTab roomId={this.props.roomId} />,
));
tabs.push(new Tab(
ROOM_SECURITY_TAB,
_td("Security & Privacy"),
"mx_RoomSettingsDialog_securityIcon",
<SecurityRoomSettingsTab roomId={this.props.roomId} />,
));
tabs.push(new Tab(
ROOM_ROLES_TAB,
_td("Roles & Permissions"),
"mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab roomId={this.props.roomId} />,
));
tabs.push(new Tab(
ROOM_NOTIFICATIONS_TAB,
_td("Notifications"),
"mx_RoomSettingsDialog_notificationsIcon",
<NotificationSettingsTab roomId={this.props.roomId} />,
));
if (SettingsStore.isFeatureEnabled("feature_bridge_state")) {
if (SettingsStore.getValue("feature_bridge_state")) {
tabs.push(new Tab(
ROOM_BRIDGES_TAB,
_td("Bridges"),
"mx_RoomSettingsDialog_bridgesIcon",
<BridgeSettingsTab roomId={this.props.roomId} />,
@ -85,6 +97,7 @@ export default class RoomSettingsDialog extends React.Component {
}
tabs.push(new Tab(
ROOM_ADVANCED_TAB,
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
@ -63,6 +64,7 @@ export default class RoomUpgradeWarningDialog extends React.Component {
};
render() {
const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -96,8 +98,11 @@ export default class RoomUpgradeWarningDialog extends React.Component {
<p>
{_t(
"This usually only affects how the room is processed on the server. If you're " +
"having problems with your Riot, please <a>report a bug</a>.",
{}, {
"having problems with your %(brand)s, please <a>report a bug</a>.",
{
brand,
},
{
"a": (sub) => {
return <a href='#' onClick={this._openBugReportDialog}>{sub}</a>;
},

View file

@ -0,0 +1,124 @@
/*
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 BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
import { EchoStore } from "../../../stores/local-echo/EchoStore";
import { formatTime } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore";
import { RoomEchoContext } from "../../../stores/local-echo/RoomEchoContext";
import RoomAvatar from "../avatars/RoomAvatar";
import { TransactionStatus } from "../../../stores/local-echo/EchoTransaction";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
onFinished: (bool) => void;
}
export default class ServerOfflineDialog extends React.PureComponent<IProps> {
public componentDidMount() {
EchoStore.instance.on(UPDATE_EVENT, this.onEchosUpdated);
}
public componentWillUnmount() {
EchoStore.instance.off(UPDATE_EVENT, this.onEchosUpdated);
}
private onEchosUpdated = () => {
this.forceUpdate(); // no state to worry about
};
private renderTimeline(): React.ReactElement[] {
return EchoStore.instance.contexts.map((c, i) => {
if (!c.firstFailedTime) return null; // not useful
if (!(c instanceof RoomEchoContext)) throw new Error("Cannot render unknown context: " + c);
const header = (
<div className="mx_ServerOfflineDialog_content_context_timeline_header">
<RoomAvatar width={24} height={24} room={c.room} />
<span>{c.room.name}</span>
</div>
);
const entries = c.transactions
.filter(t => t.status === TransactionStatus.Error || t.didPreviouslyFail)
.map((t, j) => {
let button = <Spinner w={19} h={19} />;
if (t.status === TransactionStatus.Error) {
button = (
<AccessibleButton kind="link" onClick={() => t.run()}>{_t("Resend")}</AccessibleButton>
);
}
return (
<div className="mx_ServerOfflineDialog_content_context_txn" key={`txn-${j}`}>
<span className="mx_ServerOfflineDialog_content_context_txn_desc">
{t.auditName}
</span>
{button}
</div>
);
});
return (
<div className="mx_ServerOfflineDialog_content_context" key={`context-${i}`}>
<div className="mx_ServerOfflineDialog_content_context_timestamp">
{formatTime(c.firstFailedTime, SettingsStore.getValue("showTwelveHourTimestamps"))}
</div>
<div className="mx_ServerOfflineDialog_content_context_timeline">
{header}
{entries}
</div>
</div>
)
});
}
public render() {
let timeline = this.renderTimeline().filter(c => !!c); // remove nulls for next check
if (timeline.length === 0) {
timeline = [<div key={1}>{_t("You're all caught up.")}</div>];
}
const serverName = MatrixClientPeg.getHomeserverName();
return <BaseDialog title={_t("Server isn't responding")}
className='mx_ServerOfflineDialog'
contentId='mx_Dialog_content'
onFinished={this.props.onFinished}
hasCancel={true}
>
<div className="mx_ServerOfflineDialog_content">
<p>{_t(
"Your server isn't responding to some of your requests. " +
"Below are some of the most likely reasons.",
)}</p>
<ul>
<li>{_t("The server (%(serverName)s) took too long to respond.", {serverName})}</li>
<li>{_t("Your firewall or anti-virus is blocking the request.")}</li>
<li>{_t("A browser extension is preventing the request.")}</li>
<li>{_t("The server is offline.")}</li>
<li>{_t("The server has denied your request.")}</li>
<li>{_t("Your area is experiencing difficulties connecting to the internet.")}</li>
<li>{_t("A connection error occurred while trying to contact the server.")}</li>
<li>{_t("The server is not configured to indicate what the problem is (CORS).")}</li>
</ul>
<hr />
<h2>{_t("Recent changes that have not yet been received")}</h2>
{timeline}
</div>
</BaseDialog>;
}
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -56,6 +57,7 @@ export default createReactClass({
},
render: function() {
const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -94,9 +96,10 @@ export default createReactClass({
<p>{ _t("We encountered an error trying to restore your previous session.") }</p>
<p>{ _t(
"If you have previously used a more recent version of Riot, your session " +
"If you have previously used a more recent version of %(brand)s, your session " +
"may be incompatible with this version. Close this window and return " +
"to the more recent version.",
{ brand },
) }</p>
<p>{ _t(

View file

@ -24,11 +24,13 @@ import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import QRCode from 'qrcode-react';
import QRCode from "../elements/QRCode";
import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import * as ContextMenu from "../../structures/ContextMenu";
import {toRightOf} from "../../structures/ContextMenu";
import {copyPlaintext, selectText} from "../../../utils/strings";
import StyledCheckbox from '../elements/StyledCheckbox';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
const socials = [
{
@ -168,13 +170,12 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
const events = this.props.target.getLiveTimeline().getEvents();
if (events.length > 0) {
checkbox = <div>
<input type="checkbox"
id="mx_ShareDialog_checkbox"
checked={this.state.linkSpecificEvent}
onChange={this.onLinkSpecificEventCheckboxClick} />
<label htmlFor="mx_ShareDialog_checkbox">
<StyledCheckbox
checked={this.state.linkSpecificEvent}
onChange={this.onLinkSpecificEventCheckboxClick}
>
{ _t('Link to most recent message') }
</label>
</StyledCheckbox>
</div>;
}
} else if (this.props.target instanceof User || this.props.target instanceof RoomMember) {
@ -184,13 +185,12 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
} else if (this.props.target instanceof MatrixEvent) {
title = _t('Share Room Message');
checkbox = <div>
<input type="checkbox"
id="mx_ShareDialog_checkbox"
<StyledCheckbox
checked={this.state.linkSpecificEvent}
onClick={this.onLinkSpecificEventCheckboxClick} />
<label htmlFor="mx_ShareDialog_checkbox">
onClick={this.onLinkSpecificEventCheckboxClick}
>
{ _t('Link to selected message') }
</label>
</StyledCheckbox>
</div>;
}
@ -211,17 +211,18 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
>
{ matrixToUrl }
</a>
<a href={matrixToUrl} className="mx_ShareDialog_matrixto_copy" onClick={this.onCopyClick}>
{ _t('COPY') }
<div>&nbsp;</div>
</a>
<AccessibleTooltipButton
title={_t("Copy")}
onClick={this.onCopyClick}
className="mx_ShareDialog_matrixto_copy"
/>
</div>
{ checkbox }
<hr />
<div className="mx_ShareDialog_split">
<div className="mx_ShareDialog_qrcode_container">
<QRCode value={matrixToUrl} size={256} logoWidth={48} logo={require("../../../../res/img/matrix-m.svg")} />
<QRCode data={matrixToUrl} width={256} />
</div>
<div className="mx_ShareDialog_social_container">
{ socials.map((social) => (

View file

@ -1,187 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2017 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 * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import { markAllDevicesKnown } from '../../../cryptodevices';
function UserUnknownDeviceList(props) {
const MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo');
const {userId, userDevices} = props;
const deviceListEntries = Object.keys(userDevices).map((deviceId) =>
<li key={deviceId}><MemberDeviceInfo device={userDevices[deviceId]} userId={userId} showDeviceId={true} /></li>,
);
return (
<ul className="mx_UnknownDeviceDialog_deviceList">
{ deviceListEntries }
</ul>
);
}
UserUnknownDeviceList.propTypes = {
userId: PropTypes.string.isRequired,
// map from deviceid -> deviceinfo
userDevices: PropTypes.object.isRequired,
};
function UnknownDeviceList(props) {
const {devices} = props;
const userListEntries = Object.keys(devices).map((userId) =>
<li key={userId}>
<p>{ userId }:</p>
<UserUnknownDeviceList userId={userId} userDevices={devices[userId]} />
</li>,
);
return <ul>{ userListEntries }</ul>;
}
UnknownDeviceList.propTypes = {
// map from userid -> deviceid -> deviceinfo
devices: PropTypes.object.isRequired,
};
export default createReactClass({
displayName: 'UnknownDeviceDialog',
propTypes: {
room: PropTypes.object.isRequired,
// map from userid -> deviceid -> deviceinfo or null if devices are not yet loaded
devices: PropTypes.object,
onFinished: PropTypes.func.isRequired,
// Label for the button that marks all devices known and tries the send again
sendAnywayLabel: PropTypes.string.isRequired,
// Label for the button that to send the event if you've verified all devices
sendLabel: PropTypes.string.isRequired,
// function to retry the request once all devices are verified / known
onSend: PropTypes.func.isRequired,
},
componentDidMount: function() {
MatrixClientPeg.get().on("deviceVerificationChanged", this._onDeviceVerificationChanged);
},
componentWillUnmount: function() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("deviceVerificationChanged", this._onDeviceVerificationChanged);
}
},
_onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) {
if (this.props.devices[userId] && this.props.devices[userId][deviceId]) {
// XXX: Mutating props :/
this.props.devices[userId][deviceId] = deviceInfo;
this.forceUpdate();
}
},
_onDismissClicked: function() {
this.props.onFinished();
},
_onSendAnywayClicked: function() {
markAllDevicesKnown(MatrixClientPeg.get(), this.props.devices);
this.props.onFinished();
this.props.onSend();
},
_onSendClicked: function() {
this.props.onFinished();
this.props.onSend();
},
render: function() {
if (this.props.devices === null) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
let warning;
if (SettingsStore.getValue("blacklistUnverifiedDevices", this.props.room.roomId)) {
warning = (
<h4>
{ _t("You are currently blacklisting unverified sessions; to send " +
"messages to these sessions you must verify them.") }
</h4>
);
} else {
warning = (
<div>
<p>
{ _t("We recommend you go through the verification process " +
"for each session to confirm they belong to their legitimate owner, " +
"but you can resend the message without verifying if you prefer.") }
</p>
</div>
);
}
let haveUnknownDevices = false;
Object.keys(this.props.devices).forEach((userId) => {
Object.keys(this.props.devices[userId]).map((deviceId) => {
const device = this.props.devices[userId][deviceId];
if (device.isUnverified() && !device.isKnown()) {
haveUnknownDevices = true;
}
});
});
const sendButtonOnClick = haveUnknownDevices ? this._onSendAnywayClicked : this._onSendClicked;
const sendButtonLabel = haveUnknownDevices ? this.props.sendAnywayLabel : this.props.sendAnywayLabel;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_UnknownDeviceDialog'
onFinished={this.props.onFinished}
title={_t('Room contains unknown sessions')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<h4>
{ _t('"%(RoomName)s" contains sessions that you haven\'t seen before.', {RoomName: this.props.room.name}) }
</h4>
{ warning }
{ _t("Unknown sessions") }:
<UnknownDeviceList devices={this.props.devices} />
</div>
<DialogButtons primaryButton={sendButtonLabel}
onPrimaryButtonClick={sendButtonOnClick}
onCancel={this._onDismissClicked} />
</BaseDialog>
);
// XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point?
// It feels like confused users will likely turn it on and then disappear in a cloud of UISIs...
},
});

View file

@ -84,7 +84,7 @@ export default class UploadConfirmDialog extends React.Component {
preview = <div>
<div>
<img className="mx_UploadConfirmDialog_fileIcon"
src={require("../../../../res/img/files.png")}
src={require("../../../../res/img/feather-customised/files.svg")}
/>
{this.props.file.name} ({filesize(this.props.file.size)})
</div>

View file

@ -22,6 +22,7 @@ import {_t, _td} from "../../../languageHandler";
import GeneralUserSettingsTab from "../settings/tabs/user/GeneralUserSettingsTab";
import SettingsStore from "../../../settings/SettingsStore";
import LabsUserSettingsTab from "../settings/tabs/user/LabsUserSettingsTab";
import AppearanceUserSettingsTab from "../settings/tabs/user/AppearanceUserSettingsTab";
import SecurityUserSettingsTab from "../settings/tabs/user/SecurityUserSettingsTab";
import NotificationUserSettingsTab from "../settings/tabs/user/NotificationUserSettingsTab";
import PreferencesUserSettingsTab from "../settings/tabs/user/PreferencesUserSettingsTab";
@ -32,16 +33,28 @@ import * as sdk from "../../../index";
import SdkConfig from "../../../SdkConfig";
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
export const USER_GENERAL_TAB = "USER_GENERAL_TAB";
export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB";
export const USER_FLAIR_TAB = "USER_FLAIR_TAB";
export const USER_NOTIFICATIONS_TAB = "USER_NOTIFICATIONS_TAB";
export const USER_PREFERENCES_TAB = "USER_PREFERENCES_TAB";
export const USER_VOICE_TAB = "USER_VOICE_TAB";
export const USER_SECURITY_TAB = "USER_SECURITY_TAB";
export const USER_LABS_TAB = "USER_LABS_TAB";
export const USER_MJOLNIR_TAB = "USER_MJOLNIR_TAB";
export const USER_HELP_TAB = "USER_HELP_TAB";
export default class UserSettingsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
initialTabId: PropTypes.string,
};
constructor() {
super();
this.state = {
mjolnirEnabled: SettingsStore.isFeatureEnabled("feature_mjolnir"),
mjolnirEnabled: SettingsStore.getValue("feature_mjolnir"),
};
}
@ -62,37 +75,50 @@ export default class UserSettingsDialog extends React.Component {
const tabs = [];
tabs.push(new Tab(
USER_GENERAL_TAB,
_td("General"),
"mx_UserSettingsDialog_settingsIcon",
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
tabs.push(new Tab(
USER_APPEARANCE_TAB,
_td("Appearance"),
"mx_UserSettingsDialog_appearanceIcon",
<AppearanceUserSettingsTab />,
));
tabs.push(new Tab(
USER_FLAIR_TAB,
_td("Flair"),
"mx_UserSettingsDialog_flairIcon",
<FlairUserSettingsTab />,
));
tabs.push(new Tab(
USER_NOTIFICATIONS_TAB,
_td("Notifications"),
"mx_UserSettingsDialog_bellIcon",
<NotificationUserSettingsTab />,
));
tabs.push(new Tab(
USER_PREFERENCES_TAB,
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
<PreferencesUserSettingsTab />,
));
tabs.push(new Tab(
USER_VOICE_TAB,
_td("Voice & Video"),
"mx_UserSettingsDialog_voiceIcon",
<VoiceUserSettingsTab />,
));
tabs.push(new Tab(
USER_SECURITY_TAB,
_td("Security & Privacy"),
"mx_UserSettingsDialog_securityIcon",
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) {
if (SdkConfig.get()['showLabsSettings']) {
tabs.push(new Tab(
USER_LABS_TAB,
_td("Labs"),
"mx_UserSettingsDialog_labsIcon",
<LabsUserSettingsTab />,
@ -100,12 +126,14 @@ export default class UserSettingsDialog extends React.Component {
}
if (this.state.mjolnirEnabled) {
tabs.push(new Tab(
USER_MJOLNIR_TAB,
_td("Ignored users"),
"mx_UserSettingsDialog_mjolnirIcon",
<MjolnirUserSettingsTab />,
));
}
tabs.push(new Tab(
USER_HELP_TAB,
_td("Help & About"),
"mx_UserSettingsDialog_helpIcon",
<HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />,
@ -121,7 +149,7 @@ export default class UserSettingsDialog extends React.Component {
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Settings")}>
<div className='ms_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} />
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
</div>
</BaseDialog>
);

View file

@ -17,10 +17,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import SettingsStore from "../../../settings/SettingsStore";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import WidgetUtils from "../../../utils/WidgetUtils";
import {SettingLevel} from "../../../settings/SettingLevel";
export default class WidgetOpenIDPermissionsDialog extends React.Component {
static propTypes = {

View file

@ -20,7 +20,6 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { MatrixClient } from 'matrix-js-sdk';
import Modal from '../../../../Modal';
import { _t } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager';
@ -89,14 +88,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
_onResetRecoveryClick = () => {
this.props.onFinished(false);
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),
{
onFinished: () => {
this._loadBackupStatus();
},
}, null, /* priority = */ false, /* static = */ true,
);
accessSecretStorage(() => {}, /* forceReset = */ true);
}
_onRecoveryKeyChange = (e) => {
@ -193,7 +185,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
// `accessSecretStorage` may prompt for storage access as needed.
const recoverInfo = await accessSecretStorage(async () => {
return MatrixClientPeg.get().restoreKeyBackupWithSecretStorage(
this.state.backupInfo,
this.state.backupInfo, undefined, undefined,
{ progressCallback: this._progressCallback },
);
});
@ -235,8 +227,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
loadError: null,
});
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupKeyStored = await MatrixClientPeg.get().isKeyBackupKeyStored();
const cli = MatrixClientPeg.get();
const backupInfo = await cli.getKeyBackupVersion();
const has4S = await cli.hasSecretStorageKey();
const backupKeyStored = has4S && await cli.isKeyBackupKeyStored();
this.setState({
backupInfo,
backupKeyStored,

View file

@ -15,13 +15,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { debounce } from 'lodash';
import classNames from 'classnames';
import React from 'react';
import PropTypes from "prop-types";
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import Field from '../../elements/Field';
import AccessibleButton from '../../elements/AccessibleButton';
import { _t } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager';
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
// so this should be plenty and allow for people putting extra whitespace in the file because
// maybe that's a thing people would do?
const KEY_FILE_MAX_SIZE = 128;
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
const VALIDATION_THROTTLE_MS = 200;
/*
* Access Secure Secret Storage by requesting the user's passphrase.
@ -36,9 +47,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
constructor(props) {
super(props);
this._fileUpload = React.createRef();
this.state = {
recoveryKey: "",
recoveryKeyValid: false,
recoveryKeyValid: null,
recoveryKeyCorrect: null,
recoveryKeyFileError: null,
forceRecoveryKey: false,
passPhrase: '',
keyMatches: null,
@ -55,18 +71,89 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
});
}
_onResetRecoveryClick = () => {
// Re-enter the access flow, but resetting storage this time around.
this.props.onFinished(false);
accessSecretStorage(() => {}, /* forceReset = */ true);
_validateRecoveryKeyOnChange = debounce(() => {
this._validateRecoveryKey();
}, VALIDATION_THROTTLE_MS);
async _validateRecoveryKey() {
if (this.state.recoveryKey === '') {
this.setState({
recoveryKeyValid: null,
recoveryKeyCorrect: null,
});
return;
}
try {
const cli = MatrixClientPeg.get();
const decodedKey = cli.keyBackupKeyFromRecoveryKey(this.state.recoveryKey);
const correct = await cli.checkSecretStorageKey(
decodedKey, this.props.keyInfo,
);
this.setState({
recoveryKeyValid: true,
recoveryKeyCorrect: correct,
});
} catch (e) {
this.setState({
recoveryKeyValid: false,
recoveryKeyCorrect: false,
});
}
}
_onRecoveryKeyChange = (e) => {
this.setState({
recoveryKey: e.target.value,
recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value),
keyMatches: null,
recoveryKeyFileError: null,
});
// also clear the file upload control so that the user can upload the same file
// the did before (otherwise the onchange wouldn't fire)
if (this._fileUpload.current) this._fileUpload.current.value = null;
// We don't use Field's validation here because a) we want it in a separate place rather
// than in a tooltip and b) we want it to display feedback based on the uploaded file
// as well as the text box. Ideally we would refactor Field's validation logic so we could
// re-use some of it.
this._validateRecoveryKeyOnChange();
}
_onRecoveryKeyFileChange = async e => {
if (e.target.files.length === 0) return;
const f = e.target.files[0];
if (f.size > KEY_FILE_MAX_SIZE) {
this.setState({
recoveryKeyFileError: true,
recoveryKeyCorrect: false,
recoveryKeyValid: false,
});
} else {
const contents = await f.text();
// test it's within the base58 alphabet. We could be more strict here, eg. require the
// right number of characters, but it's really just to make sure that what we're reading is
// text because we'll put it in the text field.
if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) {
this.setState({
recoveryKeyFileError: null,
recoveryKey: contents.trim(),
});
this._validateRecoveryKey();
} else {
this.setState({
recoveryKeyFileError: true,
recoveryKeyCorrect: false,
recoveryKeyValid: false,
recoveryKey: '',
});
}
}
}
_onRecoveryKeyFileUploadClick = () => {
this._fileUpload.current.click();
}
_onPassPhraseNext = async (e) => {
@ -106,6 +193,20 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
});
}
getKeyValidationText() {
if (this.state.recoveryKeyFileError) {
return _t("Wrong file type");
} else if (this.state.recoveryKeyCorrect) {
return _t("Looks good!");
} else if (this.state.recoveryKeyValid) {
return _t("Wrong Recovery Key");
} else if (this.state.recoveryKeyValid === null) {
return '';
} else {
return _t("Invalid Recovery Key");
}
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -118,10 +219,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
let content;
let title;
let titleClass;
if (hasPassphrase && !this.state.forceRecoveryKey) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
title = _t("Enter recovery passphrase");
title = _t("Security Phrase");
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle'];
let keyStatus;
if (this.state.keyMatches === false) {
@ -137,12 +240,15 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
content = <div>
<p>{_t(
"<b>Warning</b>: You should only do this on a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
"Access your secure message history and your cross-signing " +
"identity for verifying other sessions by entering your recovery passphrase.",
"Enter your Security Phrase or <button>Use your Security Key</button> to continue.", {},
{
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
},
)}</p>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onPassPhraseNext}>
@ -153,10 +259,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
value={this.state.passPhrase}
autoFocus={true}
autoComplete="new-password"
placeholder={_t("Security Phrase")}
/>
{keyStatus}
<DialogButtons
primaryButton={_t('Next')}
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNext}
hasCancel={true}
onCancel={this._onCancel}
@ -164,87 +271,61 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
primaryDisabled={this.state.passPhrase.length === 0}
/>
</form>
{_t(
"If you've forgotten your recovery passphrase you can "+
"<button1>use your recovery key</button1> or " +
"<button2>set up new recovery options</button2>."
, {}, {
button1: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
button2: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
</div>;
} else {
title = _t("Enter recovery key");
title = _t("Security Key");
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle'];
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let keyStatus;
if (this.state.recoveryKey.length === 0) {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus" />;
} else if (this.state.keyMatches === false) {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4E "}{_t(
"Unable to access secret storage. " +
"Please verify that you entered the correct recovery key.",
)}
</div>;
} else if (this.state.recoveryKeyValid) {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
</div>;
} else {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
</div>;
}
const feedbackClasses = classNames({
'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true,
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid': this.state.recoveryKeyCorrect === true,
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid': this.state.recoveryKeyCorrect === false,
});
const recoveryKeyFeedback = <div className={feedbackClasses}>
{this.getKeyValidationText()}
</div>;
content = <div>
<p>{_t(
"<b>Warning</b>: You should only do this on a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
"Access your secure message history and your cross-signing " +
"identity for verifying other sessions by entering your recovery key.",
)}</p>
<p>{_t("Use your Security Key to continue.")}</p>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext}>
<input className="mx_AccessSecretStorageDialog_recoveryKeyInput"
onChange={this._onRecoveryKeyChange}
value={this.state.recoveryKey}
autoFocus={true}
/>
{keyStatus}
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext} spellCheck={false}>
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
<Field
type="text"
label={_t('Security Key')}
value={this.state.recoveryKey}
onChange={this._onRecoveryKeyChange}
forceValidity={this.state.recoveryKeyCorrect}
/>
</div>
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">
{_t("or")}
</span>
<div>
<input type="file"
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
ref={this._fileUpload}
onChange={this._onRecoveryKeyFileChange}
/>
<AccessibleButton kind="primary" onClick={this._onRecoveryKeyFileUploadClick}>
{_t("Upload")}
</AccessibleButton>
</div>
</div>
{recoveryKeyFeedback}
<DialogButtons
primaryButton={_t('Next')}
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onRecoveryKeyNext}
hasCancel={true}
cancelButton={_t("Go Back")}
cancelButtonClass='danger'
onCancel={this._onCancel}
focus={false}
primaryDisabled={!this.state.recoveryKeyValid}
/>
</form>
{_t(
"If you've forgotten your recovery key you can "+
"<button>set up new recovery options</button>."
, {}, {
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
</div>;
}
@ -252,6 +333,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
<BaseDialog className='mx_AccessSecretStorageDialog'
onFinished={this.props.onFinished}
title={title}
titleClass={titleClass}
>
<div>
{content}

View file

@ -15,9 +15,36 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import {Key} from '../../../Keyboard';
import classnames from 'classnames';
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element>;
/**
* children: React's magic prop. Represents all children given to the element.
* element: (optional) The base element type. "div" by default.
* onClick: (required) Event handler for button activation. Should be
* implemented exactly like a normal onClick handler.
*/
interface IProps extends React.InputHTMLAttributes<Element> {
inputRef?: React.Ref<Element>;
element?: string;
// The kind of button, similar to how Bootstrap works.
// See available classes for AccessibleButton for options.
kind?: string;
// The ARIA role
role?: string;
// The tabIndex
tabIndex?: number;
disabled?: boolean;
className?: string;
onClick?(e?: ButtonEvent): void;
}
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
ref?: React.Ref<Element>;
}
/**
* AccessibleButton is a generic wrapper for any element that should be treated
@ -27,11 +54,19 @@ import {Key} from '../../../Keyboard';
* @param {Object} props react element properties
* @returns {Object} rendered react
*/
export default function AccessibleButton(props) {
const {element, onClick, children, kind, disabled, ...restProps} = props;
export default function AccessibleButton({
element,
onClick,
children,
kind,
disabled,
inputRef,
className,
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
if (!disabled) {
restProps.onClick = onClick;
newProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
// that might receive focus as a result of the AccessibleButtonClick action
@ -39,7 +74,7 @@ export default function AccessibleButton(props) {
// And divs which we report as role button to assistive technologies.
// Browsers handle space and enter keypresses differently and we are only adjusting to the
// inconsistencies here
restProps.onKeyDown = function(e) {
newProps.onKeyDown = (e) => {
if (e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
@ -50,7 +85,7 @@ export default function AccessibleButton(props) {
e.preventDefault();
}
};
restProps.onKeyUp = function(e) {
newProps.onKeyUp = (e) => {
if (e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
@ -64,57 +99,26 @@ export default function AccessibleButton(props) {
}
// Pass through the ref - used for keyboard shortcut access to some buttons
restProps.ref = restProps.inputRef;
delete restProps.inputRef;
newProps.ref = inputRef;
restProps.className = (restProps.className ? restProps.className + " " : "") + "mx_AccessibleButton";
if (kind) {
// We apply a hasKind class to maintain backwards compatibility with
// buttons which might not know about kind and break
restProps.className += " mx_AccessibleButton_hasKind mx_AccessibleButton_kind_" + kind;
}
if (disabled) {
restProps.className += " mx_AccessibleButton_disabled";
restProps["aria-disabled"] = true;
}
newProps.className = classnames(
"mx_AccessibleButton",
className,
{
"mx_AccessibleButton_hasKind": kind,
[`mx_AccessibleButton_kind_${kind}`]: kind,
"mx_AccessibleButton_disabled": disabled,
},
);
// React.createElement expects InputHTMLAttributes
return React.createElement(element, restProps, children);
}
/**
* children: React's magic prop. Represents all children given to the element.
* element: (optional) The base element type. "div" by default.
* onClick: (required) Event handler for button activation. Should be
* implemented exactly like a normal onClick handler.
*/
AccessibleButton.propTypes = {
children: PropTypes.node,
inputRef: PropTypes.oneOfType([
// Either a function
PropTypes.func,
// Or the instance of a DOM native element
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
element: PropTypes.string,
onClick: PropTypes.func.isRequired,
// The kind of button, similar to how Bootstrap works.
// See available classes for AccessibleButton for options.
kind: PropTypes.string,
// The ARIA role
role: PropTypes.string,
// The tabIndex
tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
disabled: PropTypes.bool,
};
AccessibleButton.defaultProps = {
element: 'div',
role: 'button',
tabIndex: "0",
tabIndex: 0,
};
AccessibleButton.displayName = "AccessibleButton";

View file

@ -16,47 +16,66 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton";
import * as sdk from "../../../index";
import Tooltip from './Tooltip';
export default class AccessibleTooltipButton extends React.PureComponent {
static propTypes = {
...AccessibleButton.propTypes,
// The tooltip to render on hover
title: PropTypes.string.isRequired,
};
interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
title: string;
tooltip?: React.ReactNode;
tooltipClassName?: string;
forceHide?: boolean;
}
state = {
hover: false,
};
interface IState {
hover: boolean;
}
export default class AccessibleTooltipButton extends React.PureComponent<ITooltipProps, IState> {
constructor(props: ITooltipProps) {
super(props);
this.state = {
hover: false,
};
}
componentDidUpdate(prevProps: Readonly<ITooltipProps>) {
if (!prevProps.forceHide && this.props.forceHide && this.state.hover) {
this.setState({
hover: false,
});
}
}
onMouseOver = () => {
if (this.props.forceHide) return;
this.setState({
hover: true,
});
};
onMouseOut = () => {
onMouseLeave = () => {
this.setState({
hover: false,
});
};
render() {
const Tooltip = sdk.getComponent("elements.Tooltip");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {title, children, ...props} = this.props;
const {title, tooltip, children, tooltipClassName, ...props} = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"
tooltipClassName="mx_AccessibleTooltipButton_tooltip"
label={title}
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title}
/> : <div />;
return (
<AccessibleButton {...props} onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} aria-label={title}>
<AccessibleButton
{...props}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
aria-label={title}
>
{ children }
{ tip }
</AccessibleButton>

View file

@ -18,7 +18,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import AccessibleButton from './AccessibleButton';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import Analytics from '../../../Analytics';

Some files were not shown because too many files have changed in this diff Show more