diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c085d0b5..b6f25f1858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +Changes in [2.3.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.3.1) (2020-04-01) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.3.0...v2.3.1) + + * Fix jitsi popout URL + [\#4327](https://github.com/matrix-org/matrix-react-sdk/pull/4327) + * Remove underscore from Jitsi conference names + [\#4324](https://github.com/matrix-org/matrix-react-sdk/pull/4324) + * Fix popout support for jitsi widgets + [\#4322](https://github.com/matrix-org/matrix-react-sdk/pull/4322) + Changes in [2.3.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.3.0) (2020-03-30) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.3.0-rc.1...v2.3.0) diff --git a/docs/settings.md b/docs/settings.md index 9b780c27c9..46e4a68fdb 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -51,6 +51,17 @@ Settings are the different options a user may set or experience in the applicati } ``` +Settings that support the config level can be set in the config file under the `settingDefaults` key (note that some settings, like the "theme" setting, are special cased in the config file): +```json +{ + ... + "settingDefaults": { + "settingName": true + }, + ... +} +``` + ### Getting values for a setting After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always diff --git a/package.json b/package.json index 7001b1cb21..c9300a13ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "2.3.0", + "version": "2.3.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index 85007aeecb..05cddf2c48 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -60,3 +60,14 @@ limitations under the License. .mx_InteractiveAuthEntryComponents_passwordSection { width: 300px; } + +.mx_InteractiveAuthEntryComponents_sso_buttons { + display: flex; + flex-direction: row; + justify-content: flex-end; + margin-top: 20px; + + .mx_AccessibleButton { + margin-left: 5px; + } +} diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index b87071745d..de39525588 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -36,12 +36,20 @@ limitations under the License. font-weight: 600; } +.mx_AccessibleButton_kind_primary_outline { + color: $button-primary-bg-color; + background-color: $button-secondary-bg-color; + border: 1px solid $button-primary-bg-color; + font-weight: 600; +} + .mx_AccessibleButton_kind_secondary { color: $accent-color; font-weight: 600; } -.mx_AccessibleButton_kind_primary.mx_AccessibleButton_disabled { +.mx_AccessibleButton_kind_primary.mx_AccessibleButton_disabled, +.mx_AccessibleButton_kind_primary_outline.mx_AccessibleButton_disabled { opacity: 0.4; } @@ -60,7 +68,14 @@ limitations under the License. background-color: $button-danger-bg-color; } -.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled { +.mx_AccessibleButton_kind_danger_outline { + color: $button-danger-bg-color; + background-color: $button-secondary-bg-color; + border: 1px solid $button-danger-bg-color; +} + +.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, +.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { color: $button-danger-disabled-fg-color; background-color: $button-danger-disabled-bg-color; } diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 0e4b1bda9e..d0e13aa06b 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -266,12 +266,31 @@ limitations under the License. } } + .mx_AccessibleButton { + padding: 8px 18px; + + &.mx_AccessibleButton_kind_primary { + color: $accent-color; + background-color: $accent-bg-color; + } + + &.mx_AccessibleButton_kind_danger { + color: $notice-primary-color; + background-color: $notice-primary-bg-color; + } + } + + .mx_VerificationShowSas .mx_AccessibleButton, .mx_UserInfo_wideButton { display: block; - margin: 16px 0; + margin: 16px 0 8px; } - button.mx_UserInfo_wideButton { - width: 100%; // FIXME get rid of this once we get rid of DialogButtons here + + + .mx_VerificationShowSas { + .mx_AccessibleButton + .mx_AccessibleButton { + margin: 8px 0; // space between buttons + } } } diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 7a3250d0ca..f06f7c187d 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -21,6 +21,7 @@ import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; import IdentityAuthClient from './IdentityAuthClient'; +import {SSOAuthEntry} from "./components/views/auth/InteractiveAuthEntryComponents"; function getIdServerDomain() { return MatrixClientPeg.get().idBaseUrl.split("://")[1]; @@ -188,11 +189,31 @@ export default class AddThreepid { // pop up an interactive auth dialog const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("Use Single Sign On to continue"), + body: _t("Confirm adding this email address by using " + + "Single Sign On to prove your identity."), + continueText: _t("Single Sign On"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("Confirm adding email"), + body: _t("Click the button below to confirm adding this email address."), + continueText: _t("Confirm"), + continueKind: "primary", + }, + }; const { finished } = Modal.createTrackedDialog('Add Email', '', InteractiveAuthDialog, { title: _t("Add Email Address"), matrixClient: MatrixClientPeg.get(), authData: e.data, makeRequest: this._makeAddThreepidOnlyRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, }); return finished; } @@ -285,11 +306,30 @@ export default class AddThreepid { // pop up an interactive auth dialog const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("Use Single Sign On to continue"), + body: _t("Confirm adding this phone number by using " + + "Single Sign On to prove your identity."), + continueText: _t("Single Sign On"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("Confirm adding phone number"), + body: _t("Click the button below to confirm adding this phone number."), + continueText: _t("Confirm"), + continueKind: "primary", + }, + }; const { finished } = Modal.createTrackedDialog('Add MSISDN', '', InteractiveAuthDialog, { title: _t("Add Phone Number"), matrixClient: MatrixClientPeg.get(), authData: e.data, makeRequest: this._makeAddThreepidOnlyRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, }); return finished; } diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.js index b7b81688e1..05054cf63a 100644 --- a/src/AsyncWrapper.js +++ b/src/AsyncWrapper.js @@ -38,7 +38,7 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this._unmounted = false; // XXX: temporary logging to try to diagnose // https://github.com/vector-im/riot-web/issues/3148 diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index ea76c85643..3c4494c09d 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -24,8 +24,7 @@ import {MatrixClientPeg} from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import SettingsStore from "./settings/SettingsStore"; -import {Capability, KnownWidgetActions} from "./widgets/WidgetApi"; -import SdkConfig from "./SdkConfig"; +import {Capability} from "./widgets/WidgetApi"; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -220,13 +219,6 @@ export default class FromWidgetPostMessageApi { } } else if (action === 'get_openid') { // Handled by caller - } else if (action === KnownWidgetActions.GetRiotWebConfig) { - if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.GetRiotWebConfig)) { - this.sendResponse(event, { - api: INBOUND_API_NAME, - config: SdkConfig.get(), - }); - } } else { console.warn('Widget postMessage event unhandled'); this.sendError(event, {message: 'The postMessage was unhandled'}); diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 34f3402334..400d29a20f 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -30,8 +30,6 @@ export const DEFAULTS: ConfigOptions = { jitsi: { // Default conference domain preferredDomain: "jitsi.riot.im", - // Default Jitsi Meet API location - externalApiUrl: "https://jitsi.riot.im/libs/external_api.min.js", }, }; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index c62aa9c3e5..d60434cf97 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -412,17 +412,20 @@ export const Commands = [ button: _t("Continue"), }, )); + + finished = finished.then(([useDefault]: any) => { + if (useDefault) { + useDefaultIdentityServer(); + return; + } + throw new Error(_t("Use an identity server to invite by email. Manage in Settings.")); + }); } else { return reject(_t("Use an identity server to invite by email. Manage in Settings.")); } } const inviter = new MultiInviter(roomId); - return success(finished.then(([useDefault]: any) => { - if (useDefault) { - useDefaultIdentityServer(); - } else if (useDefault === false) { - throw new Error(_t("Use an identity server to invite by email. Manage in Settings.")); - } + return success(finished.then(() => { return inviter.invite([address]); }).then(() => { if (inviter.getCompletionState(address) !== "invited") { diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index b602cf60fe..9eb4439816 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -37,7 +37,7 @@ export default createReactClass({ return { device: null }; }, - componentWillMount: function() { + componentDidMount: function() { this._unmounted = false; const client = MatrixClientPeg.get(); @@ -79,7 +79,7 @@ export default createReactClass({ }, onDeviceVerificationChanged: function(userId, device) { - if (userId == this.props.event.getSender()) { + if (userId === this.props.event.getSender()) { this.refreshDevice().then((dev) => { this.setState({ device: dev }); }); diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js index 3d7249b5a1..5f24fb10fa 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js @@ -30,7 +30,7 @@ import EventIndexPeg from "../../../../indexing/EventIndexPeg"; export default class ManageEventIndexDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, - } + }; constructor(props) { super(props); @@ -82,7 +82,7 @@ export default class ManageEventIndexDialog extends React.Component { } } - async componentWillMount(): void { + async componentDidMount(): void { let eventIndexSize = 0; let crawlingRoomsCount = 0; let roomCount = 0; @@ -126,16 +126,12 @@ export default class ManageEventIndexDialog extends React.Component { import("./DisableEventIndexDialog"), null, null, /* priority = */ false, /* static = */ true, ); - } - - _onDone = () => { - this.props.onFinished(true); - } + }; _onCrawlerSleepTimeChange = (e) => { this.setState({crawlerSleepTime: e.target.value}); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); - } + }; render() { let crawlerState; diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js index e8ff6e814e..6e392ea505 100644 --- a/src/components/structures/CustomRoomTagPanel.js +++ b/src/components/structures/CustomRoomTagPanel.js @@ -30,7 +30,7 @@ class CustomRoomTagPanel extends React.Component { }; } - componentWillMount() { + componentDidMount() { this._tagStoreToken = CustomRoomTagStore.addListener(() => { this.setState({tags: CustomRoomTagStore.getSortedTags()}); }); diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index a0a95ac6f1..0aababf030 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -58,7 +58,7 @@ export default class EmbeddedPage extends React.PureComponent { return sanitizeHtml(_t(s)); } - componentWillMount() { + componentDidMount() { this._unmounted = false; if (!this.props.url) { diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 524694fe95..73a8879f6d 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -428,12 +428,11 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this._unmounted = false; this._matrixClient = MatrixClientPeg.get(); this._matrixClient.on("Group.myMembership", this._onGroupMyMembership); - this._changeAvatarComponent = null; this._initGroupStore(this.props.groupId, true); this._dispatcherRef = dis.register(this._onAction); diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index b2441f1bf8..351e3bbad0 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -1,6 +1,6 @@ /* Copyright 2017 Vector Creations Ltd. -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,6 +24,8 @@ import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryCom import * as sdk from '../../index'; +export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); + export default createReactClass({ displayName: 'InteractiveAuth', @@ -47,7 +49,7 @@ export default createReactClass({ // @param {bool} status True if the operation requiring // auth was completed sucessfully, false if canceled. // @param {object} result The result of the authenticated call - // if successful, otherwise the error object + // if successful, otherwise the error object. // @param {object} extra Additional information about the UI Auth // process: // * emailSid {string} If email auth was performed, the sid of @@ -75,6 +77,15 @@ export default createReactClass({ // is managed by some other party and should not be managed by // the component itself. continueIsManaged: PropTypes.bool, + + // Called when the stage changes, or the stage's phase changes. First + // argument is the stage, second is the phase. Some stages do not have + // phases and will be counted as 0 (numeric). + onStagePhaseChange: PropTypes.func, + + // continueText and continueKind are passed straight through to the AuthEntryComponent. + continueText: PropTypes.string, + continueKind: PropTypes.string, }, getInitialState: function() { @@ -205,6 +216,16 @@ export default createReactClass({ this._authLogic.submitAuthDict(authData); }, + _onPhaseChange: function(newPhase) { + if (this.props.onStagePhaseChange) { + this.props.onStagePhaseChange(this.state.authStage, newPhase || 0); + } + }, + + _onStageCancel: function() { + this.props.onAuthFinished(false, ERROR_USER_CANCELLED); + }, + _renderCurrentStage: function() { const stage = this.state.authStage; if (!stage) { @@ -233,6 +254,10 @@ export default createReactClass({ fail={this._onAuthStageFailed} setEmailSid={this._setEmailSid} showContinue={!this.props.continueIsManaged} + onPhaseChange={this._onPhaseChange} + continueText={this.props.continueText} + continueKind={this.props.continueKind} + onCancel={this._onStageCancel} /> ); }, diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index f5e0bca67e..bbeaeb10e0 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -44,7 +44,7 @@ const LeftPanel = createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this.focusedElement = null; this._breadcrumbsWatcherRef = SettingsStore.watchSetting( diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 02ca8df0a7..51ce41e36f 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -22,7 +22,7 @@ import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { DragDropContext } from 'react-beautiful-dnd'; -import { Key, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; +import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; import { fixupColorFonts } from '../../utils/FontManager'; @@ -381,7 +381,7 @@ const LoggedInView = createReactClass({ break; case Key.SLASH: - if (ctrlCmdOnly) { + if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) { KeyboardShortcuts.toggleDialog(); handled = true; } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 258972d18d..4ef0f60e4b 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -221,7 +221,8 @@ export default createReactClass({ return {serverConfig: props}; }, - componentWillMount: function() { + // TODO: [REACT-WARNING] Move this to constructor + UNSAFE_componentWillMount: function() { SdkConfig.put(this.props.config); // Used by _viewRoom before getting state from sync @@ -261,9 +262,7 @@ export default createReactClass({ this._accountPassword = null; this._accountPasswordTimer = null; - }, - componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); this._themeWatcher = new ThemeWatcher(); this._themeWatcher.start(); diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index f1209b7b9e..f179cab6ad 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -38,7 +38,7 @@ export default createReactClass({ contextType: MatrixClientContext, }, - componentWillMount: function() { + componentDidMount: function() { this._fetch(); }, diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 8d25116827..5e556419cc 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -108,7 +108,7 @@ export default class RightPanel extends React.Component { } } - componentWillMount() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); const cli = this.context; cli.on("RoomState.members", this.onRoomStateMember); diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 664aaaf21f..0b07c10c8a 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -56,7 +56,8 @@ export default createReactClass({ }; }, - componentWillMount: function() { + // TODO: [REACT-WARNING] Move this to constructor + UNSAFE_componentWillMount: function() { this._unmounted = false; this.nextBatch = null; this.filterTimeout = null; @@ -89,9 +90,7 @@ export default createReactClass({ ), }); }); - }, - componentDidMount: function() { this.refreshRoomList(); }, diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 13b73ec02b..639f38a119 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -96,7 +96,7 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated); diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 9428de3e22..2ae2d71100 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -126,7 +126,7 @@ export default class RoomSubList extends React.PureComponent { break; case 'view_room': - if (this.state.hidden && !this.props.forceExpand && + if (this.state.hidden && !this.props.forceExpand && payload.show_room_tile && this.props.list.some((r) => r.roomId === payload.room_id) ) { this.toggle(); @@ -193,6 +193,7 @@ export default class RoomSubList extends React.PureComponent { onRoomTileClick = (roomId, ev) => { dis.dispatch({ action: 'view_room', + show_room_tile: true, // to make sure the room gets scrolled into view room_id: roomId, clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), }); diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index f1a39d6fcf..6642cce098 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -44,7 +44,7 @@ const TagPanel = createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this.unmounted = false; this.context.on("Group.myMembership", this._onGroupMyMembership); this.context.on("sync", this._onClientSync); diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index 94159a1da4..c4fba137cc 100644 --- a/src/components/structures/UserView.js +++ b/src/components/structures/UserView.js @@ -35,7 +35,7 @@ export default class UserView extends React.Component { this.state = {}; } - componentWillMount() { + componentDidMount() { if (this.props.userId) { this._loadProfileInfo(); } diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index c849edf260..dc3f4a0a2b 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -69,7 +69,7 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this.reset = null; this._checkServerLiveliness(this.props.serverConfig); }, diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index bfabc34a62..ef9d4c59c0 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -113,7 +113,8 @@ export default createReactClass({ }; }, - componentWillMount: function() { + // TODO: [REACT-WARNING] Move this to constructor + UNSAFE_componentWillMount: function() { this._unmounted = false; // map from login step type to a function which will render a control diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js index 8eef8dce11..687ab9a195 100644 --- a/src/components/structures/auth/PostRegistration.js +++ b/src/components/structures/auth/PostRegistration.js @@ -37,7 +37,7 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { // There is some assymetry between ChangeDisplayName and ChangeAvatar, // as ChangeDisplayName will auto-get the name but ChangeAvatar expects // the URL to be passed to you (because it's also used for room avatars). diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 7c6a3ea56f..45d775059c 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -120,7 +120,7 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this._unmounted = false; this._replaceClient(); }, diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index 287f7e5605..08ab7e8a61 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -54,7 +54,7 @@ export default class SoftLogout extends React.Component { this.state = { loginView: LOGIN_VIEW.LOADING, - keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount) + keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount) busy: false, password: "", diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index 63dc9d1ada..14a974668b 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -60,7 +60,7 @@ export default class CountryDropdown extends React.Component { }; } - componentWillMount() { + componentDidMount() { if (!this.props.value) { // If no value is given, we start with the default // country selected, but our parent component diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 794f270cf3..0ed93ed7d3 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -1,7 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import classnames from 'classnames'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; +import AccessibleButton from "../elements/AccessibleButton"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -59,11 +60,21 @@ import SettingsStore from "../../../settings/SettingsStore"; * session to be failed and the process to go back to the start. * setEmailSid: m.login.email.identity only: a function to be called with the * email sid after a token is requested. + * onPhaseChange: A function which is called when the stage's phase changes. If + * the stage has no phases, call this with DEFAULT_PHASE. Takes + * one argument, the phase, and is always defined/required. + * continueText: For stages which have a continue button, the text to use. + * continueKind: For stages which have a continue button, the style of button to + * use. For example, 'danger' or 'primary'. + * onCancel A function with no arguments which is called by the stage if the + * user knowingly cancelled/dismissed the authentication attempt. * * Each component may also provide the following functions (beyond the standard React ones): * focus: set the input focus appropriately in the form. */ +export const DEFAULT_PHASE = 0; + export const PasswordAuthEntry = createReactClass({ displayName: 'PasswordAuthEntry', @@ -78,6 +89,11 @@ export const PasswordAuthEntry = createReactClass({ // is the auth logic currently waiting for something to // happen? busy: PropTypes.bool, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, getInitialState: function() { @@ -175,6 +191,11 @@ export const RecaptchaAuthEntry = createReactClass({ stageParams: PropTypes.object.isRequired, errorText: PropTypes.string, busy: PropTypes.bool, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, _onCaptchaResponse: function(response) { @@ -236,6 +257,11 @@ export const TermsAuthEntry = createReactClass({ errorText: PropTypes.string, busy: PropTypes.bool, showContinue: PropTypes.bool, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, // TODO: [REACT-WARNING] Move this to constructor @@ -379,6 +405,11 @@ export const EmailIdentityAuthEntry = createReactClass({ stageState: PropTypes.object.isRequired, fail: PropTypes.func.isRequired, setEmailSid: PropTypes.func.isRequired, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, getInitialState: function() { @@ -421,6 +452,11 @@ export const MsisdnAuthEntry = createReactClass({ clientSecret: PropTypes.func, submitAuthDict: PropTypes.func.isRequired, matrixClient: PropTypes.object, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, getInitialState: function() { @@ -430,7 +466,7 @@ export const MsisdnAuthEntry = createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this._submitUrl = null; this._sid = null; this._msisdn = null; @@ -565,6 +601,91 @@ export const MsisdnAuthEntry = createReactClass({ }, }); +export class SSOAuthEntry extends React.Component { + static propTypes = { + matrixClient: PropTypes.object.isRequired, + authSessionId: PropTypes.string.isRequired, + loginType: PropTypes.string.isRequired, + submitAuthDict: PropTypes.func.isRequired, + errorText: PropTypes.string, + onPhaseChange: PropTypes.func.isRequired, + continueText: PropTypes.string, + continueKind: PropTypes.string, + onCancel: PropTypes.func, + }; + + static LOGIN_TYPE = "m.login.sso"; + static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso"; + + static PHASE_PREAUTH = 1; // button to start SSO + static PHASE_POSTAUTH = 2; // button to confirm SSO completed + + _ssoUrl: string; + + constructor(props) { + super(props); + + // We actually send the user through fallback auth so we don't have to + // deal with a redirect back to us, losing application context. + this._ssoUrl = props.matrixClient.getFallbackAuthUrl( + this.props.loginType, + this.props.authSessionId, + ); + + this.state = { + phase: SSOAuthEntry.PHASE_PREAUTH, + }; + } + + componentDidMount(): void { + this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); + } + + onStartAuthClick = () => { + // Note: We don't use PlatformPeg's startSsoAuth functions because we almost + // certainly will need to open the thing in a new tab to avoid losing application + // context. + + window.open(this._ssoUrl, '_blank'); + this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); + this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); + }; + + onConfirmClick = () => { + this.props.submitAuthDict({}); + }; + + render() { + let continueButton = null; + const cancelButton = ( + {_t("Cancel")} + ); + if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) { + continueButton = ( + {this.props.continueText || _t("Single Sign On")} + ); + } else { + continueButton = ( + {this.props.continueText || _t("Confirm")} + ); + } + + return
+ {cancelButton} + {continueButton} +
; + } +} + export const FallbackAuthEntry = createReactClass({ displayName: 'FallbackAuthEntry', @@ -574,6 +695,11 @@ export const FallbackAuthEntry = createReactClass({ loginType: PropTypes.string.isRequired, submitAuthDict: PropTypes.func.isRequired, errorText: PropTypes.string, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs @@ -599,7 +725,10 @@ export const FallbackAuthEntry = createReactClass({ } }, - _onShowFallbackClick: function() { + _onShowFallbackClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + const url = this.props.matrixClient.getFallbackAuthUrl( this.props.loginType, this.props.authSessionId, @@ -628,7 +757,7 @@ export const FallbackAuthEntry = createReactClass({ } return (
- { _t("Start authentication") } + { _t("Start authentication") } {errorSection}
); @@ -641,11 +770,12 @@ const AuthEntryComponents = [ EmailIdentityAuthEntry, MsisdnAuthEntry, TermsAuthEntry, + SSOAuthEntry, ]; export default function getEntryComponentForLoginType(loginType) { for (const c of AuthEntryComponents) { - if (c.LOGIN_TYPE == loginType) { + if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) { return c; } } diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index 54f11e8e91..eef3f86d9a 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -49,7 +49,7 @@ export default class MemberStatusMessageAvatar extends React.Component { this._button = createRef(); } - componentWillMount() { + componentDidMount() { if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) { throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user"); } diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 4fc6dd58cc..452489aa65 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -61,7 +61,7 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); this._checkPermissions(); }, diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js index 2d8dec29c7..d281656bbe 100644 --- a/src/components/views/context_menus/RoomTileContextMenu.js +++ b/src/components/views/context_menus/RoomTileContextMenu.js @@ -82,7 +82,7 @@ export default createReactClass({ }; }, - componentWillMount: function() { + componentDidMount: function() { this._unmounted = false; }, diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js index d5cba45956..5e6f06dd5d 100644 --- a/src/components/views/context_menus/StatusMessageContextMenu.js +++ b/src/components/views/context_menus/StatusMessageContextMenu.js @@ -35,7 +35,7 @@ export default class StatusMessageContextMenu extends React.Component { }; } - componentWillMount() { + componentDidMount() { const { user } = this.props; if (!user) { return; diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 14910fbf6d..2495c46327 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -55,7 +55,8 @@ export default createReactClass({ askReason: false, }), - componentWillMount: function() { + // TODO: [REACT-WARNING] Move this to constructor + UNSAFE_componentWillMount: function() { this._reasonField = null; }, diff --git a/src/components/views/dialogs/DeviceVerifyDialog.js b/src/components/views/dialogs/DeviceVerifyDialog.js index f7826b9c27..39e391269c 100644 --- a/src/components/views/dialogs/DeviceVerifyDialog.js +++ b/src/components/views/dialogs/DeviceVerifyDialog.js @@ -279,6 +279,7 @@ export default class DeviceVerifyDialog extends React.Component { onDone={this._onSasMatchesClick} isSelf={MatrixClientPeg.get().getUserId() === this.props.userId} onStartEmoji={this._onUseSasClick} + inDialog={true} />; } diff --git a/src/components/views/dialogs/IncomingSasDialog.js b/src/components/views/dialogs/IncomingSasDialog.js index c612043919..2a4ff9cec3 100644 --- a/src/components/views/dialogs/IncomingSasDialog.js +++ b/src/components/views/dialogs/IncomingSasDialog.js @@ -196,7 +196,8 @@ export default class IncomingSasDialog extends React.Component { sas={this._showSasEvent.sas} onCancel={this._onCancelClick} onDone={this._onSasMatchesClick} - isSelf={this.props.verifier.userId == MatrixClientPeg.get().getUserId()} + isSelf={this.props.verifier.userId === MatrixClientPeg.get().getUserId()} + inDialog={true} />; } diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index ff9f55cb74..af5dc5108c 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -23,6 +24,7 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; +import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; export default createReactClass({ displayName: 'InteractiveAuthDialog', @@ -44,12 +46,36 @@ export default createReactClass({ onFinished: PropTypes.func.isRequired, + // Optional title and body to show when not showing a particular stage title: PropTypes.string, + body: PropTypes.string, + + // Optional title and body pairs for particular stages and phases within + // those stages. Object structure/example is: + // { + // "org.example.stage_type": { + // 1: { + // "body": "This is a body for phase 1" of org.example.stage_type, + // "title": "Title for phase 1 of org.example.stage_type" + // }, + // 2: { + // "body": "This is a body for phase 2 of org.example.stage_type", + // "title": "Title for phase 2 of org.example.stage_type" + // "continueText": "Confirm identity with Example Auth", + // "continueKind": "danger" + // } + // } + // } + aestheticsForStagePhases: PropTypes.object, }, getInitialState: function() { return { authError: null, + + // See _onUpdateStagePhase() + uiaStage: null, + uiaStagePhase: null, }; }, @@ -57,12 +83,21 @@ export default createReactClass({ if (success) { this.props.onFinished(true, result); } else { - this.setState({ - authError: result, - }); + if (result === ERROR_USER_CANCELLED) { + this.props.onFinished(false, null); + } else { + this.setState({ + authError: result, + }); + } } }, + _onUpdateStagePhase: function(newStage, newPhase) { + // We copy the stage and stage phase params into state for title selection in render() + this.setState({uiaStage: newStage, uiaStagePhase: newPhase}); + }, + _onDismissClick: function() { this.props.onFinished(false); }, @@ -71,6 +106,23 @@ export default createReactClass({ const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + // Let's pick a title, body, and other params text that we'll show to the user. The order + // is most specific first, so stagePhase > our props > defaults. + + let title = this.state.authError ? 'Error' : (this.props.title || _t('Authentication')); + let body = this.state.authError ? null : this.props.body; + let continueText = null; + let continueKind = null; + if (!this.state.authError && this.props.aestheticsForStagePhases) { + if (this.props.aestheticsForStagePhases[this.state.uiaStage]) { + const aesthetics = this.props.aestheticsForStagePhases[this.state.uiaStage][this.state.uiaStagePhase]; + if (aesthetics && aesthetics.title) title = aesthetics.title; + if (aesthetics && aesthetics.body) body = aesthetics.body; + if (aesthetics && aesthetics.continueText) continueText = aesthetics.continueText; + if (aesthetics && aesthetics.continueKind) continueKind = aesthetics.continueKind; + } + } + let content; if (this.state.authError) { content = ( @@ -88,11 +140,16 @@ export default createReactClass({ } else { content = (
-
); @@ -101,7 +158,7 @@ export default createReactClass({ return ( { content } diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js index 76faf60eef..b9ef851d3a 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.js +++ b/src/components/views/dialogs/RoomSettingsDialog.js @@ -36,12 +36,12 @@ export default class RoomSettingsDialog extends React.Component { onFinished: PropTypes.func.isRequired, }; - componentWillMount() { + componentDidMount() { this._dispatcherRef = dis.register(this._onAction); } componentWillUnmount() { - dis.unregister(this._dispatcherRef); + if (this._dispatcherRef) dis.unregister(this._dispatcherRef); } _onAction = (payload) => { diff --git a/src/components/views/dialogs/RoomUpgradeDialog.js b/src/components/views/dialogs/RoomUpgradeDialog.js index dc734718d5..c45d82303b 100644 --- a/src/components/views/dialogs/RoomUpgradeDialog.js +++ b/src/components/views/dialogs/RoomUpgradeDialog.js @@ -30,7 +30,7 @@ export default createReactClass({ onFinished: PropTypes.func.isRequired, }, - componentWillMount: async function() { + componentDidMount: async function() { const recommended = await this.props.room.getRecommendedVersion(); this._targetVersion = recommended.version; this.setState({busy: false}); diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js index c48690bb48..fcc6e67656 100644 --- a/src/components/views/dialogs/SetPasswordDialog.js +++ b/src/components/views/dialogs/SetPasswordDialog.js @@ -75,8 +75,8 @@ export default createReactClass({ }; }, - componentWillMount: function() { - console.info('SetPasswordDialog component will mount'); + componentDidMount: function() { + console.info('SetPasswordDialog component did mount'); }, _onPasswordChanged: function(res) { diff --git a/src/components/views/dialogs/ShareDialog.js b/src/components/views/dialogs/ShareDialog.js index b42a88ceac..cf4735f608 100644 --- a/src/components/views/dialogs/ShareDialog.js +++ b/src/components/views/dialogs/ShareDialog.js @@ -121,7 +121,7 @@ export default class ShareDialog extends React.Component { }); } - componentWillMount() { + componentDidMount() { if (this.props.target instanceof Room) { const permalinkCreator = new RoomPermalinkCreator(this.props.target); permalinkCreator.load(); diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js index 69ebb72a6f..4cad13b047 100644 --- a/src/components/views/dialogs/UnknownDeviceDialog.js +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -87,7 +87,7 @@ export default createReactClass({ onSend: PropTypes.func.isRequired, }, - componentWillMount: function() { + componentDidMount: function() { MatrixClientPeg.get().on("deviceVerificationChanged", this._onDeviceVerificationChanged); }, diff --git a/src/components/views/dialogs/VerificationRequestDialog.js b/src/components/views/dialogs/VerificationRequestDialog.js index 30bff80f03..bbf3482a41 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.js +++ b/src/components/views/dialogs/VerificationRequestDialog.js @@ -48,6 +48,7 @@ export default class VerificationRequestDialog extends React.Component { verificationRequestPromise={this.props.verificationRequestPromise} onClose={this.props.onFinished} member={member} + inDialog={true} /> ; } diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 0a8bf7443b..db738664c8 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -2,6 +2,7 @@ Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -41,12 +42,30 @@ import PersistedElement from "./PersistedElement"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; +/** + * Does template substitution on a URL (or any string). Variables will be + * passed through encodeURIComponent. + * @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'. + * @param {Object} variables The key/value pairs to replace the template + * variables with. E.g. { '$bar': 'baz' }. + * @return {string} The result of replacing all template variables e.g. '/foo/baz'. + */ +function uriFromTemplate(uriTemplate, variables) { + let out = uriTemplate; + for (const [key, val] of Object.entries(variables)) { + out = out.replace( + '$' + key, encodeURIComponent(val), + ); + } + return out; +} + export default class AppTile extends React.Component { constructor(props) { super(props); // The key used for PersistedElement - this._persistKey = 'widget_' + this.props.id; + this._persistKey = 'widget_' + this.props.app.id; this.state = this._getNewState(props); @@ -78,7 +97,7 @@ export default class AppTile extends React.Component { // 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]; + return !!currentlyAllowedWidgets[newProps.app.eventId]; }; const PersistedElement = sdk.getComponent("elements.PersistedElement"); @@ -86,7 +105,7 @@ export default class AppTile extends React.Component { initialising: true, // True while we are mangling the widget URL // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), - widgetUrl: this._addWurlParams(newProps.url), + widgetUrl: this._addWurlParams(newProps.app.url), // 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: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), @@ -103,7 +122,7 @@ export default class AppTile extends React.Component { * @return {Boolean} True if capability supported */ _hasCapability(capability) { - return ActiveWidgetStore.widgetHasCapability(this.props.id, capability); + return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability); } /** @@ -125,7 +144,7 @@ export default class AppTile extends React.Component { const params = qs.parse(u.query); // Append widget ID to query parameters - params.widgetId = this.props.id; + params.widgetId = this.props.app.id; // Append current / parent URL, minus the hash because that will change when // we view a different room (ie. may change for persistent widgets) params.parentUrl = window.location.href.split('#', 2)[0]; @@ -137,35 +156,33 @@ export default class AppTile extends React.Component { isMixedContent() { const parentContentProtocol = window.location.protocol; - const u = url.parse(this.props.url); + const u = url.parse(this.props.app.url); const childContentProtocol = u.protocol; if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') { console.warn("Refusing to load mixed-content app:", - parentContentProtocol, childContentProtocol, window.location, this.props.url); + parentContentProtocol, childContentProtocol, window.location, this.props.app.url); return true; } return false; } - componentWillMount() { + componentDidMount() { // Only fetch IM token on mount if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { this.setScalarToken(); } - } - componentDidMount() { // Widget action listeners this.dispatcherRef = dis.register(this._onAction); } componentWillUnmount() { // Widget action listeners - dis.unregister(this.dispatcherRef); + if (this.dispatcherRef) dis.unregister(this.dispatcherRef); // if it's not remaining on screen, get rid of the PersistedElement container - if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) { - ActiveWidgetStore.destroyPersistentWidget(this.props.id); + if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { + ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } @@ -176,11 +193,11 @@ export default class AppTile extends React.Component { * Component initialisation is only complete when this function has resolved */ setScalarToken() { - if (!WidgetUtils.isScalarUrl(this.props.url)) { + if (!WidgetUtils.isScalarUrl(this.props.app.url)) { console.warn('Non-scalar widget, not setting scalar token!', url); this.setState({ error: null, - widgetUrl: this._addWurlParams(this.props.url), + widgetUrl: this._addWurlParams(this.props.app.url), initialising: false, }); return; @@ -191,7 +208,7 @@ export default class AppTile extends React.Component { console.warn("No integration manager - not setting scalar token", url); this.setState({ error: null, - widgetUrl: this._addWurlParams(this.props.url), + widgetUrl: this._addWurlParams(this.props.app.url), initialising: false, }); return; @@ -204,7 +221,7 @@ export default class AppTile extends React.Component { console.warn('Non-scalar manager, not setting scalar token!', url); this.setState({ error: null, - widgetUrl: this._addWurlParams(this.props.url), + widgetUrl: this._addWurlParams(this.props.app.url), initialising: false, }); return; @@ -217,7 +234,7 @@ export default class AppTile extends React.Component { 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)); + const u = url.parse(this._addWurlParams(this.props.app.url)); const params = qs.parse(u.query); if (!params.scalar_token) { params.scalar_token = encodeURIComponent(token); @@ -246,7 +263,7 @@ export default class AppTile extends React.Component { } componentWillReceiveProps(nextProps) { - if (nextProps.url !== this.props.url) { + if (nextProps.app.url !== this.props.app.url) { this._getNewState(nextProps); // Fetch IM token for new URL if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { @@ -280,7 +297,7 @@ export default class AppTile extends React.Component { } _onEditClick() { - console.log("Edit widget ID ", this.props.id); + console.log("Edit widget ID ", this.props.app.id); if (this.props.onEditClick) { this.props.onEditClick(); } else { @@ -289,13 +306,13 @@ export default class AppTile extends React.Component { IntegrationManagers.sharedInstance().openAll( this.props.room, 'type_' + this.props.type, - this.props.id, + this.props.app.id, ); } else { IntegrationManagers.sharedInstance().getPrimaryManager().open( this.props.room, 'type_' + this.props.type, - this.props.id, + this.props.app.id, ); } } @@ -303,7 +320,7 @@ export default class AppTile extends React.Component { _onSnapshotClick() { console.warn("Requesting widget snapshot"); - ActiveWidgetStore.getWidgetMessaging(this.props.id).getScreenshot() + ActiveWidgetStore.getWidgetMessaging(this.props.app.id).getScreenshot() .catch((err) => { console.error("Failed to get screenshot", err); }) @@ -351,7 +368,7 @@ export default class AppTile extends React.Component { WidgetUtils.setRoomWidget( this.props.room.roomId, - this.props.id, + this.props.app.id, ).catch((e) => { console.error('Failed to delete widget', e); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -369,7 +386,7 @@ export default class AppTile extends React.Component { } _onRevokeClicked() { - console.info("Revoke widget permissions - %s", this.props.id); + console.info("Revoke widget permissions - %s", this.props.app.id); this._revokeWidgetPermission(); } @@ -380,10 +397,10 @@ export default class AppTile extends React.Component { // Destroy the old widget messaging before starting it back up again. Some widgets // have startup routines that run when they are loaded, so we just need to reinitialize // the messaging for them. - ActiveWidgetStore.delWidgetMessaging(this.props.id); + ActiveWidgetStore.delWidgetMessaging(this.props.app.id); this._setupWidgetMessaging(); - ActiveWidgetStore.setRoomId(this.props.id, this.props.room.roomId); + ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId); this.setState({loading: false}); } @@ -391,10 +408,10 @@ 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._appFrame.current.contentWindow); - ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging); + this.props.app.id, this._getRenderedUrl(), this.props.userWidget, this._appFrame.current.contentWindow); + ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging); widgetMessaging.getCapabilities().then((requestedCapabilities) => { - console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities); + console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities); requestedCapabilities = requestedCapabilities || []; // Allow whitelisted capabilities @@ -406,7 +423,7 @@ export default class AppTile extends React.Component { }, this.props.whitelistCapabilities); if (requestedWhitelistCapabilies.length > 0 ) { - console.warn(`Widget ${this.props.id} allowing requested, whitelisted properties: ` + + console.warn(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` + requestedWhitelistCapabilies, ); } @@ -414,7 +431,7 @@ export default class AppTile extends React.Component { // TODO -- Add UI to warn about and optionally allow requested capabilities - ActiveWidgetStore.setWidgetCapabilities(this.props.id, requestedWhitelistCapabilies); + ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); if (this.props.onCapabilityRequest) { this.props.onCapabilityRequest(requestedCapabilities); @@ -422,16 +439,16 @@ export default class AppTile extends React.Component { // We only tell Jitsi widgets that we're ready because they're realistically the only ones // using this custom extension to the widget API. - if (this.props.type === 'jitsi') { + if (this.props.app.type === 'jitsi') { widgetMessaging.flagReadyToContinue(); } }).catch((err) => { - console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err); + console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err); }); } _onAction(payload) { - if (payload.widgetId === this.props.id) { + if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': if (this._hasCapability('m.sticker')) { @@ -460,9 +477,9 @@ export default class AppTile extends React.Component { _grantWidgetPermission() { const roomId = this.props.room.roomId; - console.info("Granting permission for widget to load: " + this.props.eventId); + console.info("Granting permission for widget to load: " + this.props.app.eventId); const current = SettingsStore.getValue("allowedWidgets", roomId); - current[this.props.eventId] = true; + current[this.props.app.eventId] = true; SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { this.setState({hasPermissionToLoad: true}); @@ -476,14 +493,14 @@ export default class AppTile extends React.Component { _revokeWidgetPermission() { const roomId = this.props.room.roomId; - console.info("Revoking permission for widget to load: " + this.props.eventId); + console.info("Revoking permission for widget to load: " + this.props.app.eventId); const current = SettingsStore.getValue("allowedWidgets", roomId); - current[this.props.eventId] = false; + current[this.props.app.eventId] = false; SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { this.setState({hasPermissionToLoad: false}); // Force the widget to be non-persistent (able to be deleted/forgotten) - ActiveWidgetStore.destroyPersistentWidget(this.props.id); + ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); }).catch(err => { @@ -494,8 +511,8 @@ export default class AppTile extends React.Component { formatAppTileName() { let appTileName = "No name"; - if (this.props.name && this.props.name.trim()) { - appTileName = this.props.name.trim(); + if (this.props.app.name && this.props.app.name.trim()) { + appTileName = this.props.app.name.trim(); } return appTileName; } @@ -519,20 +536,78 @@ export default class AppTile extends React.Component { } } - _getSafeUrl() { - const parsedWidgetUrl = url.parse(this.state.widgetUrl, true); + /** + * Replace the widget template variables in a url with their values + * + * @param {string} u The URL with template variables + * + * @returns {string} url with temlate variables replaced + */ + _templatedUrl(u) { + const myUserId = MatrixClientPeg.get().credentials.userId; + const myUser = MatrixClientPeg.get().getUser(myUserId); + const vars = Object.assign({ + domain: "jitsi.riot.im", // v1 widgets have this hardcoded + }, this.props.app.data, { + 'matrix_user_id': myUserId, + 'matrix_room_id': this.props.room.roomId, + 'matrix_display_name': myUser ? myUser.displayName : myUserId, + 'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '', + + // TODO: Namespace themes through some standard + 'theme': SettingsStore.getValue("theme"), + }); + + if (vars.conferenceId === undefined) { + // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets + const parsedUrl = new URL(this.props.app.url); + vars.conferenceId = parsedUrl.searchParams.get("confId"); + } + + return uriFromTemplate(u, vars); + } + + /** + * Get the URL used in the iframe + * In cases where we supply our own UI for a widget, this is an internal + * URL different to the one used if the widget is popped out to a separate + * tab / browser + * + * @returns {string} url + */ + _getRenderedUrl() { + let url; + + if (this.props.app.type === 'jitsi') { + console.log("Replacing Jitsi widget URL with local wrapper"); + url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true}); + url = this._addWurlParams(url); + } else { + url = this._getSafeUrl(this.state.widgetUrl); + } + return this._templatedUrl(url); + } + + _getPopoutUrl() { + if (this.props.app.type === 'jitsi') { + return this._templatedUrl( + WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: false}), + ); + } else { + // use app.url, not state.widgetUrl, because we want the one without + // the wURL params for the popped-out version. + return this._templatedUrl(this._getSafeUrl(this.props.app.url)); + } + } + + _getSafeUrl(u) { + const parsedWidgetUrl = url.parse(u, true); if (ENABLE_REACT_PERF) { parsedWidgetUrl.search = null; parsedWidgetUrl.query.react_perf = true; } let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol) || ( - // Check if the widget URL is a Jitsi widget in Electron - parsedWidgetUrl.protocol === 'vector:' - && parsedWidgetUrl.host === 'vector' - && parsedWidgetUrl.pathname === '/webapp/jitsi.html' - && this.props.type === 'jitsi' - )) { + if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) { safeWidgetUrl = url.format(parsedWidgetUrl); } return safeWidgetUrl; @@ -562,9 +637,9 @@ export default class AppTile extends React.Component { _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'); + // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getSafeUrl(), rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { @@ -641,7 +716,7 @@ export default class AppTile extends React.Component {