Merge remote-tracking branch 'upstream/develop' into compact-reply-rendering

This commit is contained in:
Tulir Asokan 2020-03-05 13:19:15 +02:00
commit a8c5574bc8
792 changed files with 44313 additions and 29058 deletions

View file

@ -17,7 +17,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { KeyCode } from '../../../Keyboard';
import {Key} from '../../../Keyboard';
/**
* AccessibleButton is a generic wrapper for any element that should be treated
@ -40,23 +40,23 @@ export default function AccessibleButton(props) {
// Browsers handle space and enter keypresses differently and we are only adjusting to the
// inconsistencies here
restProps.onKeyDown = function(e) {
if (e.keyCode === KeyCode.ENTER) {
if (e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.SPACE) {
if (e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
}
};
restProps.onKeyUp = function(e) {
if (e.keyCode === KeyCode.SPACE) {
if (e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.ENTER) {
if (e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
@ -67,8 +67,6 @@ export default function AccessibleButton(props) {
restProps.ref = restProps.inputRef;
delete restProps.inputRef;
restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = restProps.role || "button";
restProps.className = (restProps.className ? restProps.className + " " : "") + "mx_AccessibleButton";
if (kind) {
@ -93,19 +91,30 @@ export default function AccessibleButton(props) {
*/
AccessibleButton.propTypes = {
children: PropTypes.node,
inputRef: PropTypes.func,
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",
};
AccessibleButton.displayName = "AccessibleButton";

View file

@ -19,7 +19,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import AccessibleButton from "./AccessibleButton";
import sdk from "../../../index";
import * as sdk from "../../../index";
export default class AccessibleTooltipButton extends React.PureComponent {
static propTypes = {
@ -48,7 +48,7 @@ export default class AccessibleTooltipButton extends React.PureComponent {
const Tooltip = sdk.getComponent("elements.Tooltip");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {title, ...props} = this.props;
const {title, children, ...props} = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"
@ -57,6 +57,7 @@ export default class AccessibleTooltipButton extends React.PureComponent {
/> : <div />;
return (
<AccessibleButton {...props} onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} aria-label={title}>
{ children }
{ tip }
</AccessibleButton>
);

View file

@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import AccessibleButton from './AccessibleButton';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import * as sdk from '../../../index';
import Analytics from '../../../Analytics';
export default createReactClass({

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import * as sdk from '../../../index';
import classNames from 'classnames';
import { UserAddressType } from '../../../UserAddress';

View file

@ -19,8 +19,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
import sdk from "../../../index";
import MatrixClientPeg from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress.js';

View file

@ -19,79 +19,126 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import url from 'url';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import WidgetUtils from "../../../utils/WidgetUtils";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
export default class AppPermission extends React.Component {
static propTypes = {
url: PropTypes.string.isRequired,
creatorUserId: PropTypes.string.isRequired,
roomId: PropTypes.string.isRequired,
onPermissionGranted: PropTypes.func.isRequired,
isRoomEncrypted: PropTypes.bool,
};
static defaultProps = {
onPermissionGranted: () => {},
};
constructor(props) {
super(props);
const curlBase = this.getCurlBase();
this.state = { curlBase: curlBase};
// The first step is to pick apart the widget so we can render information about it
const urlInfo = this.parseWidgetUrl();
// The second step is to find the user's profile so we can show it on the prompt
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
let roomMember;
if (room) roomMember = room.getMember(this.props.creatorUserId);
// Set all this into the initial state
this.state = {
...urlInfo,
roomMember,
};
}
// Return string representation of content URL without query parameters
getCurlBase() {
const wurl = url.parse(this.props.url);
let curl;
let curlString;
parseWidgetUrl() {
const widgetUrl = url.parse(this.props.url);
const params = new URLSearchParams(widgetUrl.search);
const searchParams = new URLSearchParams(wurl.search);
if (WidgetUtils.isScalarUrl(wurl) && searchParams && searchParams.get('url')) {
curl = url.parse(searchParams.get('url'));
if (curl) {
curl.search = curl.query = "";
curlString = curl.format();
}
// HACK: We're relying on the query params when we should be relying on the widget's `data`.
// This is a workaround for Scalar.
if (WidgetUtils.isScalarUrl(widgetUrl) && params && params.get('url')) {
const unwrappedUrl = url.parse(params.get('url'));
return {
widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname,
isWrapped: true,
};
} else {
return {
widgetDomain: widgetUrl.host || widgetUrl.hostname,
isWrapped: false,
};
}
if (!curl && wurl) {
wurl.search = wurl.query = "";
curlString = wurl.format();
}
return curlString;
}
render() {
let e2eWarningText;
if (this.props.isRoomEncrypted) {
e2eWarningText =
<span className='mx_AppPermissionWarningTextLabel'>{ _t('NOTE: Apps are not end-to-end encrypted') }</span>;
}
const cookieWarning =
<span className='mx_AppPermissionWarningTextLabel'>
{ _t('Warning: This widget might use cookies.') }
</span>;
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
const TextWithTooltip = sdk.getComponent("views.elements.TextWithTooltip");
const displayName = this.state.roomMember ? this.state.roomMember.name : this.props.creatorUserId;
const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId;
const avatar = this.state.roomMember
? <MemberAvatar member={this.state.roomMember} width={38} height={38} />
: <BaseAvatar name={this.props.creatorUserId} width={38} height={38} />;
const warningTooltipText = (
<div>
{_t("Any of the following data may be shared:")}
<ul>
<li>{_t("Your display name")}</li>
<li>{_t("Your avatar URL")}</li>
<li>{_t("Your user ID")}</li>
<li>{_t("Your theme")}</li>
<li>{_t("Riot URL")}</li>
<li>{_t("Room ID")}</li>
<li>{_t("Widget ID")}</li>
</ul>
</div>
);
const warningTooltip = (
<TextWithTooltip tooltip={warningTooltipText} tooltipClass='mx_AppPermissionWarning_tooltip mx_Tooltip_dark'>
<span className='mx_AppPermissionWarning_helpIcon' />
</TextWithTooltip>
);
// Due to i18n limitations, we can't dedupe the code for variables in these two messages.
const warning = this.state.isWrapped
? _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s & your Integration Manager.",
{widgetDomain: this.state.widgetDomain}, {helpIcon: () => warningTooltip})
: _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s.",
{widgetDomain: this.state.widgetDomain}, {helpIcon: () => warningTooltip});
const encryptionWarning = this.props.isRoomEncrypted ? _t("Widgets do not use message encryption.") : null;
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src={require("../../../../res/img/feather-customised/warning-triangle.svg")} alt={_t('Warning!')} />
<div className='mx_AppPermissionWarning_row mx_AppPermissionWarning_bolder mx_AppPermissionWarning_smallText'>
{_t("Widget added by")}
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{_t('Do you want to load widget from URL:')}</span>
<span className='mx_AppPermissionWarningTextURL'
title={this.state.curlBase}
>{this.state.curlBase}</span>
{ e2eWarningText }
{ cookieWarning }
<div className='mx_AppPermissionWarning_row'>
{avatar}
<h4 className='mx_AppPermissionWarning_bolder'>{displayName}</h4>
<div className='mx_AppPermissionWarning_smallText'>{userId}</div>
</div>
<div className='mx_AppPermissionWarning_row mx_AppPermissionWarning_smallText'>
{warning}
</div>
<div className='mx_AppPermissionWarning_row mx_AppPermissionWarning_smallText'>
{_t("This widget may use cookies.")}&nbsp;{encryptionWarning}
</div>
<div className='mx_AppPermissionWarning_row'>
<AccessibleButton kind='primary_sm' onClick={this.props.onPermissionGranted}>
{_t("Continue")}
</AccessibleButton>
</div>
<input
className='mx_AppPermissionButton'
type='button'
value={_t('Allow')}
onClick={this.props.onPermissionGranted}
/>
</div>
);
}
}
AppPermission.propTypes = {
isRoomEncrypted: PropTypes.bool,
url: PropTypes.string.isRequired,
onPermissionGranted: PropTypes.func.isRequired,
};
AppPermission.defaultProps = {
isRoomEncrypted: false,
onPermissionGranted: function() {},
};

View file

@ -17,15 +17,15 @@ limitations under the License.
*/
import url from 'url';
import qs from 'querystring';
import React from 'react';
import qs from 'qs';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import WidgetMessaging from '../../../WidgetMessaging';
import AccessibleButton from './AccessibleButton';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import * as sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner';
@ -34,7 +34,9 @@ import dis from '../../../dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement";
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const ENABLE_REACT_PERF = false;
@ -52,7 +54,7 @@ export default class AppTile extends React.Component {
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onCancelClick = this._onCancelClick.bind(this);
this._onRevokeClicked = this._onRevokeClicked.bind(this);
this._onSnapshotClick = this._onSnapshotClick.bind(this);
this.onClickMenuBar = this.onClickMenuBar.bind(this);
this._onMinimiseClick = this._onMinimiseClick.bind(this);
@ -60,6 +62,10 @@ export default class AppTile extends React.Component {
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
this._contextMenuButton = createRef();
this._appFrame = createRef();
this._menu_bar = createRef();
}
/**
@ -69,8 +75,11 @@ export default class AppTile extends React.Component {
* @return {Object} Updated component state to be set with setState
*/
_getNewState(newProps) {
const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_');
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
// This is a function to make the impact of calling SettingsStore slightly less
const hasPermissionToLoad = () => {
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId);
return !!currentlyAllowedWidgets[newProps.eventId];
};
const PersistedElement = sdk.getComponent("elements.PersistedElement");
return {
@ -78,13 +87,13 @@ export default class AppTile extends React.Component {
// True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
widgetUrl: this._addWurlParams(newProps.url),
widgetPermissionId: widgetPermissionId,
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId,
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
error: null,
deleting: false,
widgetPageTitle: newProps.widgetPageTitle,
menuDisplayed: false,
};
}
@ -190,11 +199,22 @@ export default class AppTile extends React.Component {
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
console.warn('Non-scalar manager, not setting scalar token!', url);
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.url),
initialising: false,
});
return;
}
// Fetch the token before loading the iframe as we need it to mangle the URL
if (!this._scalarClient) {
this._scalarClient = managers.getPrimaryManager().getScalarClient();
this._scalarClient = defaultManager.getScalarClient();
}
this._scalarClient.getScalarToken().done((token) => {
this._scalarClient.getScalarToken().then((token) => {
// Append scalar_token as a query param if not already present
this._scalarClient.scalarToken = token;
const u = url.parse(this._addWurlParams(this.props.url));
@ -233,7 +253,8 @@ export default class AppTile extends React.Component {
this.setScalarToken();
}
} else if (nextProps.show && !this.props.show) {
if (this.props.waitForIframeLoad) {
// We assume that persisted widgets are loaded and don't need a spinner.
if (this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey)) {
this.setState({
loading: true,
});
@ -258,7 +279,7 @@ export default class AppTile extends React.Component {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
}
_onEditClick(e) {
_onEditClick() {
console.log("Edit widget ID ", this.props.id);
if (this.props.onEditClick) {
this.props.onEditClick();
@ -280,7 +301,7 @@ export default class AppTile extends React.Component {
}
}
_onSnapshotClick(e) {
_onSnapshotClick() {
console.warn("Requesting widget snapshot");
ActiveWidgetStore.getWidgetMessaging(this.props.id).getScreenshot()
.catch((err) => {
@ -318,14 +339,14 @@ export default class AppTile extends React.Component {
// HACK: This is a really dirty way to ensure that Jitsi cleans up
// its hold on the webcam. Without this, the widget holds a media
// stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351
if (this.refs.appFrame) {
if (this._appFrame.current) {
// In practice we could just do `+= ''` to trick the browser
// into thinking the URL changed, however I can foresee this
// being optimized out by a browser. Instead, we'll just point
// the iframe at a page that is reasonably safe to use in the
// event the iframe doesn't wink away.
// This is relative to where the Riot instance is located.
this.refs.appFrame.src = 'about:blank';
this._appFrame.current.src = 'about:blank';
}
WidgetUtils.setRoomWidget(
@ -347,13 +368,9 @@ export default class AppTile extends React.Component {
}
}
_onCancelClick() {
if (this.props.onDeleteClick) {
this.props.onDeleteClick();
} else {
console.log("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission();
}
_onRevokeClicked() {
console.info("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission();
}
/**
@ -374,7 +391,7 @@ export default class AppTile extends React.Component {
// FIXME: There's probably no reason to do this here: it should probably be done entirely
// in ActiveWidgetStore.
const widgetMessaging = new WidgetMessaging(
this.props.id, this.props.url, this.props.userWidget, this.refs.appFrame.contentWindow);
this.props.id, this.props.url, this.props.userWidget, this._appFrame.current.contentWindow);
ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging);
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities);
@ -435,24 +452,38 @@ export default class AppTile extends React.Component {
});
}
/* TODO -- Store permission in account data so that it is persisted across multiple devices */
_grantWidgetPermission() {
console.warn('Granting permission to load widget - ', this.state.widgetUrl);
localStorage.setItem(this.state.widgetPermissionId, true);
this.setState({hasPermissionToLoad: true});
// Now that we have permission, fetch the IM token
this.setScalarToken();
const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
current[this.props.eventId] = true;
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => {
this.setState({hasPermissionToLoad: true});
// Fetch a token for the integration manager, now that we're allowed to
this.setScalarToken();
}).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
}
_revokeWidgetPermission() {
console.warn('Revoking permission to load widget - ', this.state.widgetUrl);
localStorage.removeItem(this.state.widgetPermissionId);
this.setState({hasPermissionToLoad: false});
const roomId = this.props.room.roomId;
console.info("Revoking permission for widget to load: " + this.props.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
current[this.props.eventId] = false;
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => {
this.setState({hasPermissionToLoad: false});
// Force the widget to be non-persistent
ActiveWidgetStore.destroyPersistentWidget(this.props.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
// Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.destroyPersistentWidget(this.props.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
}).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
}
formatAppTileName() {
@ -467,7 +498,7 @@ export default class AppTile extends React.Component {
ev.preventDefault();
// Ignore clicks on menu bar children
if (ev.target !== this.refs.menu_bar) {
if (ev.target !== this._menu_bar.current) {
return;
}
@ -517,28 +548,36 @@ export default class AppTile extends React.Component {
}
}
_onPopoutWidgetClick(e) {
_onPopoutWidgetClick() {
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'),
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener'}).click();
{ target: '_blank', href: this._getSafeUrl(), rel: 'noreferrer noopener'}).click();
}
_onReloadWidgetClick(e) {
_onReloadWidgetClick() {
// Reload iframe in this way to avoid cross-origin restrictions
this.refs.appFrame.src = this.refs.appFrame.src;
this._appFrame.current.src = this._appFrame.current.src;
}
_onContextMenuClick = () => {
this.setState({ menuDisplayed: true });
};
_closeContextMenu = () => {
this.setState({ menuDisplayed: false });
};
render() {
let appTileBody;
// Don't render widget if it is in the process of being deleted
if (this.state.deleting) {
return <div></div>;
return <div />;
}
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to prgramatically remove the sandbox attribute, but
// because that would allow the iframe to programmatically remove the sandbox attribute, but
// this would only be for content hosted on the same origin as the riot client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
@ -558,12 +597,14 @@ export default class AppTile extends React.Component {
</div>
);
if (!this.state.hasPermissionToLoad) {
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = (
<div className={appTileBodyClass}>
<AppPermission
isRoomEncrypted={isRoomEncrypted}
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this.state.widgetUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
/>
</div>
@ -585,14 +626,9 @@ export default class AppTile extends React.Component {
appTileBody = (
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ this.state.loading && loadingElement }
{ /*
The "is" attribute in the following iframe tag is needed in order to enable rendering of the
"allow" attribute, which is unknown to react 15.
*/ }
<iframe
is
allow={iframeFeatures}
ref="appFrame"
ref={this._appFrame}
src={this._getSafeUrl()}
allowFullScreen={true}
sandbox={sandboxFlags}
@ -616,13 +652,6 @@ export default class AppTile extends React.Component {
}
}
// editing is done in scalar
const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._scalarClient && canUserModify);
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showCancelButton = (this.props.showCancel === undefined || this.props.showCancel) && !showDeleteButton;
// Picture snapshot - only show button when apps are maximised.
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
const showMinimiseButton = this.props.showMinimise && this.props.show;
const showMaximiseButton = this.props.showMinimise && !this.props.show;
@ -640,10 +669,34 @@ export default class AppTile extends React.Component {
mx_AppTileMenuBar_expanded: this.props.show,
});
return (
let contextMenu;
if (this.state.menuDisplayed) {
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._scalarClient && canUserModify);
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = (
<ContextMenu {...aboveLeftOf(elementRect, null)} onFinished={this._closeContextMenu}>
<WidgetContextMenu
onRevokeClicked={this._onRevokeClicked}
onEditClicked={showEditButton ? this._onEditClick : undefined}
onDeleteClicked={showDeleteButton ? this._onDeleteClick : undefined}
onSnapshotClicked={showPictureSnapshotButton ? this._onSnapshotClick : undefined}
onReloadClicked={this.props.showReload ? this._onReloadWidgetClick : undefined}
onFinished={this._closeContextMenu}
/>
</ContextMenu>
);
}
return <React.Fragment>
<div className={appTileClass} id={this.props.id}>
{ this.props.showMenubar &&
<div ref="menu_bar" className={menuBarClasses} onClick={this.onClickMenuBar}>
<div ref={this._menu_bar} className={menuBarClasses} onClick={this.onClickMenuBar}>
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
{ /* Minimise widget */ }
{ showMinimiseButton && <AccessibleButton
@ -661,54 +714,35 @@ export default class AppTile extends React.Component {
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ /* Reload widget */ }
{ this.props.showReload && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_reload"
title={_t('Reload widget')}
onClick={this._onReloadWidgetClick}
/> }
{ /* Popout widget */ }
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
{ /* Snapshot widget */ }
{ showPictureSnapshotButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_snapshot"
title={_t('Picture')}
onClick={this._onSnapshotClick}
/> }
{ /* Edit widget */ }
{ showEditButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_edit"
title={_t('Edit')}
onClick={this._onEditClick}
/> }
{ /* Delete widget */ }
{ showDeleteButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_delete"
title={_t('Delete widget')}
onClick={this._onDeleteClick}
/> }
{ /* Cancel widget */ }
{ showCancelButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_cancel"
title={_t('Revoke widget access')}
onClick={this._onCancelClick}
{ /* Context menu */ }
{ <ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t('More options')}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/> }
</span>
</div> }
{ appTileBody }
</div>
);
{ contextMenu }
</React.Fragment>;
}
}
AppTile.displayName ='AppTile';
AppTile.displayName = 'AppTile';
AppTile.propTypes = {
id: PropTypes.string.isRequired,
eventId: PropTypes.string, // required for room widgets
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import * as sdk from '../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';

View file

@ -17,11 +17,13 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
// XXX: This component is *not* cross-signing aware. Once everything is
// cross-signing, this component should just go away.
export default createReactClass({
displayName: 'DeviceVerifyButtons',
@ -59,7 +61,7 @@ export default createReactClass({
Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: this.props.userId,
device: this.state.device,
});
}, null, /* priority = */ false, /* static = */ true);
},
onUnverifyClick: function() {

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Aidan Gauland
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.
@ -23,7 +24,7 @@ import { _t } from '../../../languageHandler';
/**
* Basic container for buttons in modal dialogs.
*/
module.exports = createReactClass({
export default createReactClass({
displayName: "DialogButtons",
propTypes: {
@ -33,12 +34,19 @@ module.exports = createReactClass({
// A node to insert into the cancel button instead of default "Cancel"
cancelButton: PropTypes.node,
// If true, make the primary button a form submit button (input type="submit")
primaryIsSubmit: PropTypes.bool,
// onClick handler for the primary button.
onPrimaryButtonClick: PropTypes.func.isRequired,
onPrimaryButtonClick: PropTypes.func,
// should there be a cancel button? default: true
hasCancel: PropTypes.bool,
// The class of the cancel button, only used if a cancel button is
// enabled
cancelButtonClass: PropTypes.node,
// onClick handler for the cancel button.
onCancel: PropTypes.func,
@ -68,16 +76,26 @@ module.exports = createReactClass({
primaryButtonClassName += " " + this.props.primaryButtonClass;
}
let cancelButton;
if (this.props.cancelButton || this.props.hasCancel) {
cancelButton = <button onClick={this._onCancelClick} disabled={this.props.disabled}>
cancelButton = <button
// important: the default type is 'submit' and this button comes before the
// primary in the DOM so will get form submissions unless we make it not a submit.
type="button"
onClick={this._onCancelClick}
className={this.props.cancelButtonClass}
disabled={this.props.disabled}
>
{ this.props.cancelButton || _t("Cancel") }
</button>;
}
return (
<div className="mx_Dialog_buttons">
{ cancelButton }
{ this.props.children }
<button className={primaryButtonClassName}
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}
className={primaryButtonClassName}
onClick={this.props.onPrimaryButtonClick}
autoFocus={this.props.focus}
disabled={this.props.disabled || this.props.primaryDisabled}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default class DirectorySearchBox extends React.Component {

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations 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.
@ -15,11 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler';
import {Key} from "../../../Keyboard";
class MenuOption extends React.Component {
constructor(props) {
@ -48,9 +50,14 @@ class MenuOption extends React.Component {
mx_Dropdown_option_highlight: this.props.highlighted,
});
return <div className={optClasses}
return <div
id={this.props.id}
className={optClasses}
onClick={this._onClick}
onMouseEnter={this._onMouseEnter}
role="option"
aria-selected={this.props.highlighted}
ref={this.props.inputRef}
>
{ this.props.children }
</div>;
@ -66,6 +73,7 @@ MenuOption.propTypes = {
dropdownKey: PropTypes.string,
onClick: PropTypes.func.isRequired,
onMouseEnter: PropTypes.func.isRequired,
inputRef: PropTypes.any,
};
/*
@ -86,8 +94,6 @@ export default class Dropdown extends React.Component {
this._onRootClick = this._onRootClick.bind(this);
this._onDocumentClick = this._onDocumentClick.bind(this);
this._onMenuOptionClick = this._onMenuOptionClick.bind(this);
this._onInputKeyPress = this._onInputKeyPress.bind(this);
this._onInputKeyUp = this._onInputKeyUp.bind(this);
this._onInputChange = this._onInputChange.bind(this);
this._collectRoot = this._collectRoot.bind(this);
this._collectInputTextBox = this._collectInputTextBox.bind(this);
@ -111,6 +117,7 @@ export default class Dropdown extends React.Component {
}
componentWillMount() {
this._button = createRef();
// Listen for all clicks on the document so we can close the
// menu when the user clicks somewhere else
document.addEventListener('click', this._onDocumentClick, false);
@ -169,40 +176,49 @@ export default class Dropdown extends React.Component {
}
}
_onMenuOptionClick(dropdownKey) {
_close() {
this.setState({
expanded: false,
});
this.props.onOptionChange(dropdownKey);
}
_onInputKeyPress(e) {
// This needs to be on the keypress event because otherwise
// it can't cancel the form submission
if (e.key == 'Enter') {
this.setState({
expanded: false,
});
this.props.onOptionChange(this.state.highlightedOption);
e.preventDefault();
// their focus was on the input, its getting unmounted, move it to the button
if (this._button.current) {
this._button.current.focus();
}
}
_onInputKeyUp(e) {
// These keys don't generate keypress events and so needs to
// be on keyup
if (e.key == 'Escape') {
this.setState({
expanded: false,
});
} else if (e.key == 'ArrowDown') {
this.setState({
highlightedOption: this._nextOption(this.state.highlightedOption),
});
} else if (e.key == 'ArrowUp') {
this.setState({
highlightedOption: this._prevOption(this.state.highlightedOption),
});
_onMenuOptionClick(dropdownKey) {
this._close();
this.props.onOptionChange(dropdownKey);
}
_onInputKeyDown = (e) => {
let handled = true;
// These keys don't generate keypress events and so needs to be on keyup
switch (e.key) {
case Key.ENTER:
this.props.onOptionChange(this.state.highlightedOption);
// fallthrough
case Key.ESCAPE:
this._close();
break;
case Key.ARROW_DOWN:
this.setState({
highlightedOption: this._nextOption(this.state.highlightedOption),
});
break;
case Key.ARROW_UP:
this.setState({
highlightedOption: this._prevOption(this.state.highlightedOption),
});
break;
default:
handled = false;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
}
@ -250,20 +266,34 @@ export default class Dropdown extends React.Component {
return keys[(index - 1) % keys.length];
}
_scrollIntoView(node) {
if (node) {
node.scrollIntoView({
block: "nearest",
behavior: "auto",
});
}
}
_getMenuOptions() {
const options = React.Children.map(this.props.children, (child) => {
const highlighted = this.state.highlightedOption === child.key;
return (
<MenuOption key={child.key} dropdownKey={child.key}
highlighted={this.state.highlightedOption == child.key}
<MenuOption
id={`${this.props.id}__${child.key}`}
key={child.key}
dropdownKey={child.key}
highlighted={highlighted}
onMouseEnter={this._setHighlightedOption}
onClick={this._onMenuOptionClick}
inputRef={highlighted ? this._scrollIntoView : undefined}
>
{ child }
</MenuOption>
);
});
if (options.length === 0) {
return [<div key="0" className="mx_Dropdown_option">
return [<div key="0" className="mx_Dropdown_option" role="option">
{ _t("No results") }
</div>];
}
@ -279,23 +309,35 @@ export default class Dropdown extends React.Component {
let menu;
if (this.state.expanded) {
if (this.props.searchEnabled) {
currentValue = <input type="text" className="mx_Dropdown_option"
ref={this._collectInputTextBox} onKeyPress={this._onInputKeyPress}
onKeyUp={this._onInputKeyUp}
onChange={this._onInputChange}
value={this.state.searchQuery}
/>;
currentValue = (
<input
type="text"
className="mx_Dropdown_option"
ref={this._collectInputTextBox}
onKeyDown={this._onInputKeyDown}
onChange={this._onInputChange}
value={this.state.searchQuery}
role="combobox"
aria-autocomplete="list"
aria-activedescendant={`${this.props.id}__${this.state.highlightedOption}`}
aria-owns={`${this.props.id}_listbox`}
aria-disabled={this.props.disabled}
aria-label={this.props.label}
/>
);
}
menu = <div className="mx_Dropdown_menu" style={menuStyle}>
{ this._getMenuOptions() }
</div>;
menu = (
<div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
{ this._getMenuOptions() }
</div>
);
}
if (!currentValue) {
const selectedChild = this.props.getShortOption ?
this.props.getShortOption(this.props.value) :
this.childrenByKey[this.props.value];
currentValue = <div className="mx_Dropdown_option">
currentValue = <div className="mx_Dropdown_option" id={`${this.props.id}_value`}>
{ selectedChild }
</div>;
}
@ -311,9 +353,18 @@ export default class Dropdown extends React.Component {
// Note the menu sits inside the AccessibleButton div so it's anchored
// to the input, but overflows below it. The root contains both.
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
<AccessibleButton className="mx_Dropdown_input mx_no_textinput" onClick={this._onInputClick}>
<AccessibleButton
className="mx_Dropdown_input mx_no_textinput"
onClick={this._onInputClick}
aria-haspopup="listbox"
aria-expanded={this.state.expanded}
disabled={this.props.disabled}
inputRef={this._button}
aria-label={this.props.label}
aria-describedby={`${this.props.id}_value`}
>
{ currentValue }
<span className="mx_Dropdown_arrow"></span>
<span className="mx_Dropdown_arrow" />
{ menu }
</AccessibleButton>
</div>;
@ -321,6 +372,7 @@ export default class Dropdown extends React.Component {
}
Dropdown.propTypes = {
id: PropTypes.string.isRequired,
// The width that the dropdown should be. If specified,
// the dropped-down part of the menu will be set to this
// width.
@ -340,4 +392,6 @@ Dropdown.propTypes = {
value: PropTypes.string,
// negative for consistency with HTML
disabled: PropTypes.bool,
// ARIA label
label: PropTypes.string.isRequired,
};

View file

@ -15,12 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {Key} from "../../../Keyboard";
module.exports = createReactClass({
export default createReactClass({
displayName: 'EditableText',
propTypes: {
@ -65,7 +65,7 @@ module.exports = createReactClass({
componentWillReceiveProps: function(nextProps) {
if (nextProps.initialValue !== this.props.initialValue) {
this.value = nextProps.initialValue;
if (this.refs.editable_div) {
if (this._editable_div.current) {
this.showPlaceholder(!this.value);
}
}
@ -76,24 +76,27 @@ module.exports = createReactClass({
// as React doesn't play nice with contentEditable.
this.value = '';
this.placeholder = false;
this._editable_div = createRef();
},
componentDidMount: function() {
this.value = this.props.initialValue;
if (this.refs.editable_div) {
if (this._editable_div.current) {
this.showPlaceholder(!this.value);
}
},
showPlaceholder: function(show) {
if (show) {
this.refs.editable_div.textContent = this.props.placeholder;
this.refs.editable_div.setAttribute("class", this.props.className + " " + this.props.placeholderClassName);
this._editable_div.current.textContent = this.props.placeholder;
this._editable_div.current.setAttribute("class", this.props.className
+ " " + this.props.placeholderClassName);
this.placeholder = true;
this.value = '';
} else {
this.refs.editable_div.textContent = this.value;
this.refs.editable_div.setAttribute("class", this.props.className);
this._editable_div.current.textContent = this.value;
this._editable_div.current.setAttribute("class", this.props.className);
this.placeholder = false;
}
},
@ -120,7 +123,7 @@ module.exports = createReactClass({
this.value = this.props.initialValue;
this.showPlaceholder(!this.value);
this.onValueChanged(false);
this.refs.editable_div.blur();
this._editable_div.current.blur();
},
onValueChanged: function(shouldSubmit) {
@ -219,7 +222,7 @@ module.exports = createReactClass({
</div>;
} else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editableEl = <div ref="editable_div"
editableEl = <div ref={this._editable_div}
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}

View file

@ -16,8 +16,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import Promise from 'bluebird';
import * as sdk from '../../../index';
/**
* A component which wraps an EditableText, with a spinner while updates take
@ -26,13 +25,13 @@ import Promise from 'bluebird';
* Parent components should supply an 'onSubmit' callback which returns a
* promise; a spinner is shown until the promise resolves.
*
* The parent can also supply a 'getIntialValue' callback, which works in a
* The parent can also supply a 'getInitialValue' callback, which works in a
* similarly asynchronous way. If this is not provided, the initial value is
* taken from the 'initialValue' property.
*/
export default class EditableTextContainer extends React.Component {
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this._unmounted = false;
this.state = {
@ -51,7 +50,7 @@ export default class EditableTextContainer extends React.Component {
this.setState({busy: true});
this.props.getInitialValue().done(
this.props.getInitialValue().then(
(result) => {
if (this._unmounted) { return; }
this.setState({
@ -83,7 +82,7 @@ export default class EditableTextContainer extends React.Component {
errorString: null,
});
this.props.onSubmit(value).done(
this.props.onSubmit(value).then(
() => {
if (this._unmounted) { return; }
this.setState({

View file

@ -15,9 +15,9 @@ limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg';
import Modal from '../../../Modal';
@ -54,7 +54,7 @@ export default class ErrorBoundary extends React.PureComponent {
if (!PlatformPeg.get()) return;
MatrixClientPeg.get().stopClient();
MatrixClientPeg.get().store.deleteAllData().done(() => {
MatrixClientPeg.get().store.deleteAllData().then(() => {
PlatformPeg.get().reload();
});
};

View file

@ -29,7 +29,7 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande
if (onToggle) {
onToggle();
}
}, [expanded]);
}, [expanded]); // eslint-disable-line react-hooks/exhaustive-deps
const eventIds = events.map((e) => e.getId()).join(',');
@ -62,12 +62,12 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande
</div>
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">
<span className="mx_EventListSummary_avatars" onClick={toggleExpanded}>
{ avatars }
</span>
<span className="mx_EventListSummary_avatars" onClick={toggleExpanded}>
{ avatars }
</span>
<span className="mx_TextualEvent mx_EventListSummary_summary">
{ summaryText }
</span>
{ summaryText }
</span>
</div>
</div>
</div>

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { debounce } from 'lodash';
// Invoke validation from user input (when typing, etc.) at most once every N ms.
@ -66,10 +66,14 @@ export default class Field extends React.PureComponent {
this.state = {
valid: undefined,
feedback: undefined,
focused: false,
};
}
onFocus = (ev) => {
this.setState({
focused: true,
});
this.validate({
focused: true,
});
@ -88,6 +92,9 @@ export default class Field extends React.PureComponent {
};
onBlur = (ev) => {
this.setState({
focused: false,
});
this.validate({
focused: false,
});
@ -112,7 +119,9 @@ export default class Field extends React.PureComponent {
allowEmpty,
});
if (feedback) {
// this method is async and so we may have been blurred since the method was called
// if we have then hide the feedback as withValidation does
if (this.state.focused && feedback) {
this.setState({
valid,
feedback,

View file

@ -18,9 +18,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClient} from 'matrix-js-sdk';
import FlairStore from '../../../stores/FlairStore';
import dis from '../../../dispatcher';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
class FlairAvatar extends React.Component {
@ -40,7 +40,7 @@ class FlairAvatar extends React.Component {
}
render() {
const httpUrl = this.context.matrixClient.mxcUrlToHttp(
const httpUrl = this.context.mxcUrlToHttp(
this.props.groupProfile.avatarUrl, 16, 16, 'scale', false);
const tooltip = this.props.groupProfile.name ?
`${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`:
@ -62,9 +62,7 @@ FlairAvatar.propTypes = {
}),
};
FlairAvatar.contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
FlairAvatar.contextType = MatrixClientContext;
export default class Flair extends React.Component {
constructor() {
@ -92,7 +90,7 @@ export default class Flair extends React.Component {
for (const groupId of groups) {
let groupProfile = null;
try {
groupProfile = await FlairStore.getGroupProfileCached(this.context.matrixClient, groupId);
groupProfile = await FlairStore.getGroupProfileCached(this.context, groupId);
} catch (err) {
console.error('Could not get profile for group', groupId, err);
}
@ -134,6 +132,4 @@ Flair.propTypes = {
groups: PropTypes.arrayOf(PropTypes.string),
};
Flair.contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
Flair.contextType = MatrixClientContext;

View file

@ -0,0 +1,28 @@
/*
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 AccessibleButton from "./AccessibleButton";
export default function FormButton(props) {
const {className, label, kind, ...restProps} = props;
const newClassName = (className || "") + " mx_FormButton";
const allProps = Object.assign({}, restProps,
{className: newClassName, kind: kind || "primary", children: [label]});
return React.createElement(AccessibleButton, allProps);
}
FormButton.propTypes = AccessibleButton.propTypes;

View file

@ -15,14 +15,14 @@ limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
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="view_my_groups"
<ActionButton className="mx_GroupsButton" action="toggle_my_groups"
label={_t("Communities")}
size={props.size}
tooltip={true}

View file

@ -0,0 +1,34 @@
/*
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 AccessibleButton from "./AccessibleButton";
export default function IconButton(props) {
const {icon, className, ...restProps} = props;
let newClassName = (className || "") + " mx_IconButton";
newClassName = newClassName + " mx_IconButton_icon_" + icon;
const allProps = Object.assign({}, restProps, {className: newClassName});
return React.createElement(AccessibleButton, allProps);
}
IconButton.propTypes = Object.assign({
icon: PropTypes.string,
}, AccessibleButton.propTypes);

View file

@ -19,15 +19,14 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
const MatrixClientPeg = require('../../../MatrixClientPeg');
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {formatDate} from '../../../DateUtils';
const filesize = require('filesize');
const AccessibleButton = require('../../../components/views/elements/AccessibleButton');
const Modal = require('../../../Modal');
const sdk = require('../../../index');
import { _t } from '../../../languageHandler';
import filesize from "filesize";
import AccessibleButton from "./AccessibleButton";
import Modal from "../../../Modal";
import * as sdk from "../../../index";
import {Key} from "../../../Keyboard";
export default class ImageView extends React.Component {
static propTypes = {
@ -62,7 +61,7 @@ export default class ImageView extends React.Component {
}
onKeyDown = (ev) => {
if (ev.keyCode === 27) { // escape
if (ev.key === Key.ESCAPE) {
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
@ -84,7 +83,7 @@ export default class ImageView extends React.Component {
title: _t('Error'),
description: _t('You cannot delete this image. (%(code)s)', {code: code}),
});
}).done();
});
},
});
};
@ -92,7 +91,7 @@ export default class ImageView extends React.Component {
getName() {
let name = this.props.name;
if (name && this.props.link) {
name = <a href={ this.props.link } target="_blank" rel="noopener">{ name }</a>;
name = <a href={ this.props.link } target="_blank" rel="noreferrer noopener">{ name }</a>;
}
return name;
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import createReactClass from 'create-react-class';
module.exports = createReactClass({
export default createReactClass({
displayName: 'InlineSpinner',
render: function() {

View file

@ -90,7 +90,7 @@ function isInLowerLeftHalf(x, y, rect) {
* tooltip along one edge of the target.
*/
export default class InteractiveTooltip extends React.Component {
propTypes: {
static propTypes = {
// Content to show in the tooltip
content: PropTypes.node.isRequired,
// Function to call when visibility of the tooltip changes

View file

@ -18,9 +18,10 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import * as sdk from '../../../index';
import * as languageHandler from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
function languageMatchesSearchQuery(query, language) {
if (language.label.toUpperCase().indexOf(query.toUpperCase()) == 0) return true;
@ -49,7 +50,7 @@ export default class LanguageDropdown extends React.Component {
this.setState({langs});
}).catch(() => {
this.setState({langs: ['en']});
}).done();
});
if (!this.props.value) {
// If no value is given, we start with the first
@ -105,9 +106,14 @@ export default class LanguageDropdown extends React.Component {
value = this.props.value || language;
}
return <Dropdown className={this.props.className}
onOptionChange={this.props.onOptionChange} onSearchChange={this._onSearchChange}
searchEnabled={true} value={value}
return <Dropdown
id="mx_LanguageDropdown"
className={this.props.className}
onOptionChange={this.props.onOptionChange}
onSearchChange={this._onSearchChange}
searchEnabled={true}
value={value}
label={_t("Language Dropdown")}
>
{ options }
</Dropdown>;

View file

@ -15,9 +15,7 @@ limitations under the License.
*/
import React from "react";
const OVERFLOW_ITEMS = 20;
const OVERFLOW_MARGIN = 5;
import PropTypes from 'prop-types';
class ItemRange {
constructor(topCount, renderCount, bottomCount) {
@ -27,11 +25,22 @@ class ItemRange {
}
contains(range) {
// don't contain empty ranges
// as it will prevent clearing the list
// once it is scrolled far enough out of view
if (!range.renderCount && this.renderCount) {
return false;
}
return range.topCount >= this.topCount &&
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
}
expand(amount) {
// don't expand ranges that won't render anything
if (this.renderCount === 0) {
return this;
}
const topGrow = Math.min(amount, this.topCount);
const bottomGrow = Math.min(amount, this.bottomCount);
return new ItemRange(
@ -40,52 +49,86 @@ class ItemRange {
this.bottomCount - bottomGrow,
);
}
totalSize() {
return this.topCount + this.renderCount + this.bottomCount;
}
}
export default class LazyRenderList extends React.Component {
constructor(props) {
super(props);
const renderRange = LazyRenderList.getVisibleRangeFromProps(props).expand(OVERFLOW_ITEMS);
this.state = {renderRange};
this.state = {};
}
static getDerivedStateFromProps(props, state) {
const range = LazyRenderList.getVisibleRangeFromProps(props);
const intersectRange = range.expand(props.overflowMargin);
const renderRange = range.expand(props.overflowItems);
const listHasChangedSize = !!state.renderRange && renderRange.totalSize() !== state.renderRange.totalSize();
// only update render Range if the list has shrunk/grown and we need to adjust padding OR
// if the new range + overflowMargin isn't contained by the old anymore
if (listHasChangedSize || !state.renderRange || !state.renderRange.contains(intersectRange)) {
return {renderRange};
}
return null;
}
static getVisibleRangeFromProps(props) {
const {items, itemHeight, scrollTop, height} = props;
const length = items ? items.length : 0;
const topCount = Math.max(0, Math.floor(scrollTop / itemHeight));
const topCount = Math.min(Math.max(0, Math.floor(scrollTop / itemHeight)), length);
const itemsAfterTop = length - topCount;
const renderCount = Math.min(Math.ceil(height / itemHeight), itemsAfterTop);
const visibleItems = height !== 0 ? Math.ceil(height / itemHeight) : 0;
const renderCount = Math.min(visibleItems, itemsAfterTop);
const bottomCount = itemsAfterTop - renderCount;
return new ItemRange(topCount, renderCount, bottomCount);
}
componentWillReceiveProps(props) {
const state = this.state;
const range = LazyRenderList.getVisibleRangeFromProps(props);
const intersectRange = range.expand(OVERFLOW_MARGIN);
const prevSize = this.props.items ? this.props.items.length : 0;
const listHasChangedSize = props.items.length !== prevSize;
// only update renderRange if the list has shrunk/grown and we need to adjust padding or
// if the new range isn't contained by the old anymore
if (listHasChangedSize || !state.renderRange || !state.renderRange.contains(intersectRange)) {
this.setState({renderRange: range.expand(OVERFLOW_ITEMS)});
}
}
render() {
const {itemHeight, items, renderItem} = this.props;
const {renderRange} = this.state;
const paddingTop = renderRange.topCount * itemHeight;
const paddingBottom = renderRange.bottomCount * itemHeight;
const {topCount, renderCount, bottomCount} = renderRange;
const paddingTop = topCount * itemHeight;
const paddingBottom = bottomCount * itemHeight;
const renderedItems = (items || []).slice(
renderRange.topCount,
renderRange.topCount + renderRange.renderCount,
topCount,
topCount + renderCount,
);
return (<div style={{paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`}}>
{ renderedItems.map(renderItem) }
</div>);
const element = this.props.element || "div";
const elementProps = {
"style": {paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`},
"className": this.props.className,
};
return React.createElement(element, elementProps, renderedItems.map(renderItem));
}
}
LazyRenderList.defaultProps = {
overflowItems: 20,
overflowMargin: 5,
};
LazyRenderList.propTypes = {
// height in pixels of the component returned by `renderItem`
itemHeight: PropTypes.number.isRequired,
// function to turn an element of `items` into a react component
renderItem: PropTypes.func.isRequired,
// scrollTop of the viewport (minus the height of any content above this list like other `LazyRenderList`s)
scrollTop: PropTypes.number.isRequired,
// the height of the viewport this content is scrolled in
height: PropTypes.number.isRequired,
// all items for the list. These should not be react components, see `renderItem`.
items: PropTypes.array,
// the amount of items to scroll before causing a rerender,
// should typically be less than `overflowItems` unless applying
// margins in the parent component when using multiple LazyRenderList in one viewport.
// use 0 to only rerender when items will come into view.
overflowMargin: PropTypes.number,
// the amount of items to add at the top and bottom to render,
// so not every scroll of causes a rerender.
overflowItems: PropTypes.number,
};

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";

View file

@ -21,10 +21,10 @@ import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import sdk from "../../../index";
import * as sdk from "../../../index";
import {MatrixEvent} from "matrix-js-sdk";
module.exports = createReactClass({
export default createReactClass({
displayName: 'MemberEventListSummary',
propTypes: {

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
module.exports = createReactClass({
export default createReactClass({
displayName: 'MessageSpinner',
render: function() {

View file

@ -1,5 +1,6 @@
/*
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.
@ -19,10 +20,10 @@ import createReactClass from 'create-react-class';
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
module.exports = createReactClass({
export default createReactClass({
displayName: 'PersistentApp',
getInitialState: function() {
@ -67,13 +68,15 @@ module.exports = createReactClass({
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
});
const app = WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.sender, persistentWidgetInRoomId,
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
persistentWidgetInRoomId, appEvent.getId(),
);
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
id={app.id}
eventId={app.eventId}
url={app.url}
name={app.name}
type={app.type}

View file

@ -17,15 +17,15 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import classNames from 'classnames';
import { Room, RoomMember, MatrixClient } from 'matrix-js-sdk';
import { Room, RoomMember } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { getDisplayAliasForRoom } from '../../../Rooms';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import FlairStore from "../../../stores/FlairStore";
import {getPrimaryPermalinkEntity} from "../../../utils/permalinks/Permalinks";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
// For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
@ -66,17 +66,6 @@ const Pill = createReactClass({
isSelected: PropTypes.bool,
},
childContextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getChildContext() {
return {
matrixClient: this._matrixClient,
};
},
getInitialState() {
return {
// ID/alias of the room/user
@ -127,7 +116,7 @@ const Pill = createReactClass({
}
break;
case Pill.TYPE_USER_MENTION: {
const localMember = nextProps.room.getMember(resourceId);
const localMember = nextProps.room ? nextProps.room.getMember(resourceId) : undefined;
member = localMember;
if (!localMember) {
member = new RoomMember(null, resourceId);
@ -138,7 +127,8 @@ const Pill = createReactClass({
case Pill.TYPE_ROOM_MENTION: {
const localRoom = resourceId[0] === '#' ?
MatrixClientPeg.get().getRooms().find((r) => {
return r.getAliases().includes(resourceId);
return r.getCanonicalAlias() === resourceId ||
r.getAltAliases().includes(resourceId);
}) : MatrixClientPeg.get().getRoom(resourceId);
room = localRoom;
if (!localRoom) {
@ -221,7 +211,7 @@ const Pill = createReactClass({
if (room) {
linkText = "@room";
if (this.props.shouldShowPillAvatar) {
avatar = <RoomAvatar room={room} width={16} height={16} />;
avatar = <RoomAvatar room={room} width={16} height={16} aria-hidden="true" />;
}
pillClass = 'mx_AtRoomPill';
}
@ -235,7 +225,7 @@ const Pill = createReactClass({
member.rawDisplayName = member.rawDisplayName || '';
linkText = member.rawDisplayName;
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16} />;
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
}
pillClass = 'mx_UserPill';
href = null;
@ -246,12 +236,12 @@ const Pill = createReactClass({
case Pill.TYPE_ROOM_MENTION: {
const room = this.state.room;
if (room) {
linkText = (room ? getDisplayAliasForRoom(room) : null) || resource;
linkText = resource;
if (this.props.shouldShowPillAvatar) {
avatar = <RoomAvatar room={room} width={16} height={16} />;
avatar = <RoomAvatar room={room} width={16} height={16} aria-hidden="true" />;
}
pillClass = 'mx_RoomPill';
}
pillClass = 'mx_RoomPill';
}
break;
case Pill.TYPE_GROUP_MENTION: {
@ -261,7 +251,7 @@ const Pill = createReactClass({
linkText = groupId;
if (this.props.shouldShowPillAvatar) {
avatar = <BaseAvatar name={name || groupId} width={16} height={16}
avatar = <BaseAvatar name={name || groupId} width={16} height={16} aria-hidden="true"
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 16, 16) : null} />;
}
pillClass = 'mx_GroupPill';
@ -271,20 +261,22 @@ const Pill = createReactClass({
}
const classes = classNames("mx_Pill", pillClass, {
"mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId,
"mx_UserPill_me": userId === MatrixClientPeg.get().getUserId(),
"mx_UserPill_selected": this.props.isSelected,
});
if (this.state.pillType) {
return this.props.inMessage ?
<a className={classes} href={href} onClick={onClick} title={resource} data-offset-key={this.props.offsetKey}>
{ avatar }
{ linkText }
</a> :
<span className={classes} title={resource} data-offset-key={this.props.offsetKey}>
{ avatar }
{ linkText }
</span>;
return <MatrixClientContext.Provider value={this._matrixClient}>
{ this.props.inMessage ?
<a className={classes} href={href} onClick={onClick} title={resource} data-offset-key={this.props.offsetKey}>
{ avatar }
{ linkText }
</a> :
<span className={classes} title={resource} data-offset-key={this.props.offsetKey}>
{ avatar }
{ linkText }
</span> }
</MatrixClientContext.Provider>;
} else {
// Deliberately render nothing if the URL isn't recognised
return null;

View file

@ -22,7 +22,7 @@ import { _t } from '../../../languageHandler';
import Field from "./Field";
import {Key} from "../../../Keyboard";
module.exports = createReactClass({
export default createReactClass({
displayName: 'PowerSelector',
propTypes: {
@ -129,10 +129,11 @@ module.exports = createReactClass({
render: function() {
let picker;
const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label;
if (this.state.custom) {
picker = (
<Field id={`powerSelector_custom_${this.props.powerLevelKey}`} type="number"
label={this.props.label || _t("Power level")} max={this.props.maxValue}
label={label} max={this.props.maxValue}
onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} onChange={this.onCustomChange}
value={String(this.state.customValue)} disabled={this.props.disabled} />
);
@ -151,7 +152,7 @@ module.exports = createReactClass({
picker = (
<Field id={`powerSelector_notCustom_${this.props.powerLevelKey}`} element="select"
label={this.props.label || _t("Power level")} onChange={this.onSelectChange}
label={label} onChange={this.onSelectChange}
value={String(this.state.selectValue)} disabled={this.props.disabled}>
{options}
</Field>

View file

@ -1,5 +1,6 @@
/*
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.
@ -18,7 +19,7 @@ import React from "react";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
module.exports = createReactClass({
export default createReactClass({
displayName: 'ProgressBar',
propTypes: {
value: PropTypes.number,

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 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.
@ -15,14 +16,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import * as sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import {MatrixEvent} from 'matrix-js-sdk';
import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
// This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
@ -36,12 +39,10 @@ export default class ReplyThread extends React.Component {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
static contextType = MatrixClientContext;
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.state = {
// The loaded events to be rendered as linear-replies
@ -101,6 +102,15 @@ export default class ReplyThread extends React.Component {
if (html) html = this.stripHTMLReply(html);
}
if (!body) body = ""; // Always ensure we have a body, for reasons.
// Escape the body to use as HTML below.
// We also run a nl2br over the result to fix the fallback representation. We do this
// after converting the text to safe HTML to avoid user-provided BR's from being converted.
if (!html) html = escapeHtml(body).replace(/\n/g, '<br/>');
// dev note: do not rely on `body` being safe for HTML usage below.
const evLink = permalinkCreator.forEvent(ev.getId());
const userLink = makeUserPermalink(ev.getSender());
const mxid = ev.getSender();
@ -110,7 +120,7 @@ export default class ReplyThread extends React.Component {
case 'm.text':
case 'm.notice': {
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>${html || body}</blockquote></mx-reply>`;
+ `<br>${html}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `<${mxid}> ${lines[0]}`;
@ -140,7 +150,7 @@ export default class ReplyThread extends React.Component {
break;
case 'm.emote': {
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> * `
+ `<a href="${userLink}">${mxid}</a><br>${html || body}</blockquote></mx-reply>`;
+ `<a href="${userLink}">${mxid}</a><br>${html}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `* <${mxid}> ${lines[0]}`;
@ -176,7 +186,7 @@ export default class ReplyThread extends React.Component {
componentWillMount() {
this.unmounted = false;
this.room = this.context.matrixClient.getRoom(this.props.parentEv.getRoomId());
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.room.on("Room.redaction", this.onRoomRedaction);
// same event handler as Room.redaction as for both we just do forceUpdate
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
@ -248,7 +258,7 @@ export default class ReplyThread extends React.Component {
try {
// ask the client to fetch the event we want using the context API, only interface to do so is to ask
// for a timeline with that event, but once it is loaded we can use findEventById to look up the ev map
await this.context.matrixClient.getEventTimeline(this.room.getUnfilteredTimelineSet(), eventId);
await this.context.getEventTimeline(this.room.getUnfilteredTimelineSet(), eventId);
} catch (e) {
// if it fails catch the error and return early, there's no point trying to find the event in this case.
// Return null as it is falsey and thus should be treated as an error (as the event cannot be resolved).
@ -289,7 +299,7 @@ export default class ReplyThread extends React.Component {
} else if (this.state.loadedEv) {
const ev = this.state.loadedEv;
const Pill = sdk.getComponent('elements.Pill');
const room = this.context.matrixClient.getRoom(ev.getRoomId());
const room = this.context.getRoom(ev.getRoomId());
header = <blockquote className="mx_ReplyThread">
{
_t('<a>In reply to</a> <pill>', {}, {

View file

@ -16,15 +16,17 @@ limitations under the License.
import { _t } from '../../../languageHandler';
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import * as sdk from '../../../index';
import withValidation from './Validation';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
// Controlled form component wrapping Field for inputting a room alias scoped to a given domain
export default class RoomAliasField extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
domain: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string.isRequired,
};
constructor(props) {
@ -53,6 +55,7 @@ export default class RoomAliasField extends React.PureComponent {
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength} />
);
}
@ -61,7 +64,7 @@ export default class RoomAliasField extends React.PureComponent {
if (this.props.onChange) {
this.props.onChange(this._asFullAlias(ev.target.value));
}
}
};
_onValidate = async (fieldState) => {
const result = await this._validationRules(fieldState);
@ -89,6 +92,7 @@ export default class RoomAliasField extends React.PureComponent {
invalid: () => _t("Please provide a room alias"),
}, {
key: "taken",
final: true,
test: async ({value}) => {
if (!value) {
return true;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import * as sdk from '../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';

View file

@ -0,0 +1,41 @@
/*
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 PropTypes from 'prop-types';
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
const SSOButton = ({matrixClient, loginType, ...props}) => {
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType);
};
return (
<AccessibleButton {...props} kind="primary" onClick={onClick}>
{_t("Sign in with single sign-on")}
</AccessibleButton>
);
};
SSOButton.propTypes = {
matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client
loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis
};
export default SSOButton;

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.
@ -21,7 +22,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import { _t } from '../../../languageHandler';
import ToggleSwitch from "./ToggleSwitch";
module.exports = createReactClass({
export default createReactClass({
displayName: 'SettingsFlag',
propTypes: {
name: PropTypes.string.isRequired,

View file

@ -1,5 +1,6 @@
/*
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.
@ -17,7 +18,7 @@ limitations under the License.
import React from "react";
import createReactClass from 'create-react-class';
module.exports = createReactClass({
export default createReactClass({
displayName: 'Spinner',
render: function() {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import * as sdk from '../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';

View file

@ -24,8 +24,8 @@ export default class SyntaxHighlight extends React.Component {
children: PropTypes.node,
};
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this._ref = this._ref.bind(this);
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,20 +16,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
import * as ContextualMenu from '../../structures/ContextualMenu';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import FlairStore from '../../../stores/FlairStore';
import GroupStore from '../../../stores/GroupStore';
import TagOrderStore from '../../../stores/TagOrderStore';
import {ContextMenu, toRightOf} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
// a thing to click on for the user to filter the visible rooms in the RoomList to:
@ -44,8 +45,8 @@ export default createReactClass({
tag: PropTypes.string,
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
statics: {
contextType: MatrixClientContext,
},
getInitialState() {
@ -54,10 +55,14 @@ export default createReactClass({
hover: false,
// The profile data of the group if this.props.tag is a group ID
profile: null,
// Whether or not the context menu is open
menuDisplayed: false,
};
},
componentDidMount() {
this._contextMenuButton = createRef();
this.unmounted = false;
if (this.props.tag[0] === '+') {
FlairStore.addListener('updateGroupProfile', this._onFlairStoreUpdated);
@ -77,7 +82,7 @@ export default createReactClass({
_onFlairStoreUpdated() {
if (this.unmounted) return;
FlairStore.getGroupProfileCached(
this.context.matrixClient,
this.context,
this.props.tag,
).then((profile) => {
if (this.unmounted) return;
@ -107,45 +112,6 @@ export default createReactClass({
}
},
_openContextMenu: function(x, y, chevronOffset) {
// Hide the (...) immediately
this.setState({ hover: false });
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
ContextualMenu.createMenu(TagTileContextMenu, {
chevronOffset: chevronOffset,
left: x,
top: y,
tag: this.props.tag,
onFinished: () => {
this.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
},
onContextButtonClick: function(e) {
e.preventDefault();
e.stopPropagation();
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = elementRect.right + window.pageXOffset + 3;
const chevronOffset = 12;
let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
this._openContextMenu(x, y, chevronOffset);
},
onContextMenu: function(e) {
e.preventDefault();
const chevronOffset = 12;
this._openContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset);
},
onMouseOver: function() {
this.setState({hover: true});
},
@ -154,15 +120,30 @@ export default createReactClass({
this.setState({hover: false});
},
openMenu: function(e) {
// Prevent the TagTile onClick event firing as well
e.stopPropagation();
e.preventDefault();
this.setState({
menuDisplayed: true,
hover: false,
});
},
closeMenu: function() {
this.setState({
menuDisplayed: false,
});
},
render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Tooltip = sdk.getComponent('elements.Tooltip');
const profile = this.state.profile || {};
const name = profile.name || this.props.tag;
const avatarHeight = 40;
const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
const httpUrl = profile.avatarUrl ? this.context.mxcUrlToHttp(
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : null;
@ -181,26 +162,40 @@ export default createReactClass({
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
}
const tip = this.state.hover ?
<Tooltip className="mx_TagTile_tooltip" label={name} /> :
<div />;
// FIXME: this ought to use AccessibleButton for a11y but that causes onMouseOut/onMouseOver to fire too much
const contextButton = this.state.hover || this.state.menuDisplayed ?
<div className="mx_TagTile_context_button" onClick={this.onContextButtonClick}>
<div className="mx_TagTile_context_button" onClick={this.openMenu} ref={this._contextMenuButton}>
{ "\u00B7\u00B7\u00B7" }
</div> : <div />;
return <AccessibleButton className={className} onClick={this.onClick} onContextMenu={this.onContextMenu}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar
name={name}
idName={this.props.tag}
url={httpUrl}
width={avatarHeight}
height={avatarHeight}
/>
{ tip }
{ contextButton }
{ badgeElement }
</div>
</AccessibleButton>;
</div> : <div ref={this._contextMenuButton} />;
let contextMenu;
if (this.state.menuDisplayed) {
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
contextMenu = (
<ContextMenu {...toRightOf(elementRect)} onFinished={this.closeMenu}>
<TagTileContextMenu tag={this.props.tag} onFinished={this.closeMenu} />
</ContextMenu>
);
}
const AccessibleTooltipButton = sdk.getComponent("elements.AccessibleTooltipButton");
return <React.Fragment>
<AccessibleTooltipButton className={className} onClick={this.onClick} onContextMenu={this.openMenu} title={name}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar
name={name}
idName={this.props.tag}
url={httpUrl}
width={avatarHeight}
height={avatarHeight}
/>
{ contextButton }
{ badgeElement }
</div>
</AccessibleTooltipButton>
{ contextMenu }
</React.Fragment>;
},
});

View file

@ -16,12 +16,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import * as sdk from '../../../index';
export default class TextWithTooltip extends React.Component {
static propTypes = {
class: PropTypes.string,
tooltip: PropTypes.string.isRequired,
tooltipClass: PropTypes.string,
tooltip: PropTypes.node.isRequired,
};
constructor() {
@ -49,6 +50,7 @@ export default class TextWithTooltip extends React.Component {
<Tooltip
label={this.props.tooltip}
visible={this.state.hover}
tooltipClassName={this.props.tooltipClass}
className={"mx_TextWithTooltip_tooltip"} />
</span>
);

View file

@ -1,5 +1,6 @@
/*
Copyright 2015 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.
@ -83,4 +84,4 @@ Tinter.registerTintable(function() {
}
});
module.exports = TintableSvg;
export default TintableSvg;

View file

@ -18,47 +18,32 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import * as sdk from "../../../index";
import {KeyCode} from "../../../Keyboard";
// Controlled Toggle Switch element
// Controlled Toggle Switch element, written with Accessibility in mind
const ToggleSwitch = ({checked, disabled=false, onChange, ...props}) => {
const _onClick = (e) => {
e.stopPropagation();
e.preventDefault();
if (disabled) return;
onChange(!checked);
};
const _onKeyDown = (e) => {
e.stopPropagation();
e.preventDefault();
if (disabled) return;
if (e.keyCode === KeyCode.ENTER || e.keyCode === KeyCode.SPACE) {
onChange(!checked);
}
};
const classes = classNames({
"mx_ToggleSwitch": true,
"mx_ToggleSwitch_on": checked,
"mx_ToggleSwitch_enabled": !disabled,
});
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
return (
<div {...props}
<AccessibleButton {...props}
className={classes}
onClick={_onClick}
onKeyDown={_onKeyDown}
role="checkbox"
role="switch"
aria-checked={checked}
aria-disabled={disabled}
tabIndex={0}
>
<div className="mx_ToggleSwitch_ball" />
</div>
</AccessibleButton>
);
};

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
@ -26,7 +27,7 @@ import classNames from 'classnames';
const MIN_TOOLTIP_HEIGHT = 25;
module.exports = createReactClass({
export default createReactClass({
displayName: 'Tooltip',
propTypes: {
@ -100,7 +101,9 @@ module.exports = createReactClass({
const parent = ReactDOM.findDOMNode(this).parentNode;
let style = {};
style = this._updatePosition(style);
style.display = "block";
// Hide the entire container when not visible. This prevents flashing of the tooltip
// if it is not meant to be visible on first mount.
style.display = this.props.visible ? "block" : "none";
const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, {
"mx_Tooltip_visible": this.props.visible,

View file

@ -17,9 +17,9 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import * as sdk from '../../../index';
module.exports = createReactClass({
export default createReactClass({
displayName: 'TooltipButton',
getInitialState: function() {

View file

@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
module.exports = createReactClass({
export default createReactClass({
displayName: 'TruncatedList',
propTypes: {

View file

@ -1,5 +1,6 @@
/*
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.
@ -14,12 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
module.exports = createReactClass({
export default createReactClass({
displayName: 'UserSelector',
propTypes: {
@ -34,6 +35,10 @@ module.exports = createReactClass({
};
},
UNSAFE_componentWillMount: function() {
this._user_id_input = createRef();
},
addUser: function(user_id) {
if (this.props.selected_users.indexOf(user_id == -1)) {
this.props.onChange(this.props.selected_users.concat([user_id]));
@ -47,20 +52,20 @@ module.exports = createReactClass({
},
onAddUserId: function() {
this.addUser(this.refs.user_id_input.value);
this.refs.user_id_input.value = "";
this.addUser(this._user_id_input.current.value);
this._user_id_input.current.value = "";
},
render: function() {
const self = this;
return (
<div>
<ul className="mx_UserSelector_UserIdList" ref="list">
<ul className="mx_UserSelector_UserIdList">
{ this.props.selected_users.map(function(user_id, i) {
return <li key={user_id}>{ user_id } - <span onClick={function() {self.removeUser(user_id);}}>X</span></li>;
}) }
</ul>
<input type="text" ref="user_id_input" defaultValue="" className="mx_UserSelector_userIdInput" placeholder={_t("ex. @bob:example.com")} />
<input type="text" ref={this._user_id_input} defaultValue="" className="mx_UserSelector_userIdInput" placeholder={_t("ex. @bob:example.com")} />
<button onClick={this.onAddUserId} className="mx_UserSelector_AddUserId">
{ _t("Add User") }
</button>

View file

@ -28,9 +28,11 @@ import classNames from 'classnames';
* An array of rules describing how to check to input value. Each rule in an object
* and may have the following properties:
* - `key`: A unique ID for the rule. Required.
* - `skip`: A function used to determine whether the rule should even be evaluated.
* - `test`: A function used to determine the rule's current validity. Required.
* - `valid`: Function returning text to show when the rule is valid. Only shown if set.
* - `invalid`: Function returning text to show when the rule is invalid. Only shown if set.
* - `final`: A Boolean if true states that this rule will only be considered if all rules before it returned valid.
* @returns {Function}
* A validation function that takes in the current input value and returns
* the overall validity and a feedback UI that can be rendered for more detail.
@ -51,9 +53,20 @@ export default function withValidation({ description, rules }) {
if (!rule.key || !rule.test) {
continue;
}
if (!valid && rule.final) {
continue;
}
const data = { value, allowEmpty };
if (rule.skip && rule.skip.call(this, data)) {
continue;
}
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const ruleValid = await rule.test.call(this, { value, allowEmpty });
const ruleValid = await rule.test.call(this, data);
valid = valid && ruleValid;
if (ruleValid && rule.valid) {
// If the rule's result is valid and has text to show for

View file

@ -0,0 +1,167 @@
/*
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 PropTypes from "prop-types";
import {replaceableComponent} from "../../../../utils/replaceableComponent";
import {MatrixClientPeg} from "../../../../MatrixClientPeg";
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import {ToDeviceChannel} from "matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel";
import {decodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
import Spinner from "../Spinner";
import * as QRCode from "qrcode";
const CODE_VERSION = 0x02; // the version of binary QR codes we support
const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format
const MODE_VERIFY_OTHER_USER = 0x00; // Verifying someone who isn't us
const MODE_VERIFY_SELF_TRUSTED = 0x01; // We trust the master key
const MODE_VERIFY_SELF_UNTRUSTED = 0x02; // We do not trust the master key
@replaceableComponent("views.elements.crypto.VerificationQRCode")
export default class VerificationQRCode extends React.PureComponent {
static propTypes = {
prefix: PropTypes.string.isRequired,
version: PropTypes.number.isRequired,
mode: PropTypes.number.isRequired,
transactionId: PropTypes.string.isRequired, // or requestEventId
firstKeyB64: PropTypes.string.isRequired,
secondKeyB64: PropTypes.string.isRequired,
secretB64: PropTypes.string.isRequired,
};
static async getPropsForRequest(verificationRequest: VerificationRequest) {
const cli = MatrixClientPeg.get();
const myUserId = cli.getUserId();
const otherUserId = verificationRequest.otherUserId;
let mode = MODE_VERIFY_OTHER_USER;
if (myUserId === otherUserId) {
// Mode changes depending on whether or not we trust the master cross signing key
const myTrust = cli.checkUserTrust(myUserId);
if (myTrust.isCrossSigningVerified()) {
mode = MODE_VERIFY_SELF_TRUSTED;
} else {
mode = MODE_VERIFY_SELF_UNTRUSTED;
}
}
const requestEvent = verificationRequest.requestEvent;
const transactionId = requestEvent.getId()
? requestEvent.getId()
: ToDeviceChannel.getTransactionId(requestEvent);
const qrProps = {
prefix: BINARY_PREFIX,
version: CODE_VERSION,
mode,
transactionId,
firstKeyB64: '', // worked out shortly
secondKeyB64: '', // worked out shortly
secretB64: verificationRequest.encodedSharedSecret,
};
const myCrossSigningInfo = cli.getStoredCrossSigningForUser(myUserId);
const myDevices = (await cli.getStoredDevicesForUser(myUserId)) || [];
if (mode === MODE_VERIFY_OTHER_USER) {
// First key is our master cross signing key
qrProps.firstKeyB64 = myCrossSigningInfo.getId("master");
// Second key is the other user's master cross signing key
const otherUserCrossSigningInfo = cli.getStoredCrossSigningForUser(otherUserId);
qrProps.secondKeyB64 = otherUserCrossSigningInfo.getId("master");
} else if (mode === MODE_VERIFY_SELF_TRUSTED) {
// First key is our master cross signing key
qrProps.firstKeyB64 = myCrossSigningInfo.getId("master");
// Second key is the other device's device key
const otherDevice = verificationRequest.targetDevice;
const otherDeviceId = otherDevice ? otherDevice.deviceId : null;
const device = myDevices.find(d => d.deviceId === otherDeviceId);
qrProps.secondKeyB64 = device.getFingerprint();
} else if (mode === MODE_VERIFY_SELF_UNTRUSTED) {
// First key is our device's key
qrProps.firstKeyB64 = cli.getDeviceEd25519Key();
// Second key is what we think our master cross signing key is
qrProps.secondKeyB64 = myCrossSigningInfo.getId("master");
}
return qrProps;
}
constructor(props) {
super(props);
this.state = {
dataUri: null,
};
this.generateQrCode();
}
componentDidUpdate(prevProps): void {
if (JSON.stringify(this.props) === JSON.stringify(prevProps)) return; // No prop change
this.generateQRCode();
}
async generateQrCode() {
let buf = Buffer.alloc(0); // we'll concat our way through life
const appendByte = (b: number) => {
const tmpBuf = Buffer.from([b]);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendInt = (i: number) => {
const tmpBuf = Buffer.alloc(2);
tmpBuf.writeInt16BE(i, 0);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendStr = (s: string, enc: string, withLengthPrefix = true) => {
const tmpBuf = Buffer.from(s, enc);
if (withLengthPrefix) appendInt(tmpBuf.byteLength);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendEncBase64 = (b64: string) => {
const b = decodeBase64(b64);
const tmpBuf = Buffer.from(b);
buf = Buffer.concat([buf, tmpBuf]);
};
// Actually build the buffer for the QR code
appendStr(this.props.prefix, "ascii", false);
appendByte(this.props.version);
appendByte(this.props.mode);
appendStr(this.props.transactionId, "utf-8");
appendEncBase64(this.props.firstKeyB64);
appendEncBase64(this.props.secondKeyB64);
appendEncBase64(this.props.secretB64);
// Now actually assemble the QR code's data URI
const uri = await QRCode.toDataURL([{data: buf, mode: 'byte'}], {
errorCorrectionLevel: 'L', // we want it as trivial-looking as possible
});
this.setState({dataUri: uri});
}
render() {
if (!this.state.dataUri) {
return <div className='mx_VerificationQRCode'><Spinner /></div>;
}
return <img src={this.state.dataUri} className='mx_VerificationQRCode' />;
}
}