diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 2e2a404338..aa2a6b7f0b 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,50 +1,31 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. -src/components/structures/RoomDirectory.js -src/components/structures/RoomStatusBar.js -src/components/structures/ScrollPanel.js -src/components/structures/SearchBox.js -src/components/structures/UploadBar.js -src/components/views/avatars/MemberAvatar.js -src/components/views/create_room/RoomAlias.js -src/components/views/dialogs/SetPasswordDialog.js -src/components/views/elements/AddressSelector.js -src/components/views/elements/DirectorySearchBox.js -src/components/views/elements/MemberEventListSummary.js -src/components/views/elements/UserSelector.js -src/components/views/globals/NewVersionBar.js -src/components/views/messages/MFileBody.js -src/components/views/messages/TextualBody.js -src/components/views/room_settings/ColorSettings.js -src/components/views/rooms/Autocomplete.js -src/components/views/rooms/AuxPanel.js -src/components/views/rooms/LinkPreviewWidget.js -src/components/views/rooms/MemberInfo.js -src/components/views/rooms/MemberList.js -src/components/views/rooms/RoomList.js -src/components/views/rooms/RoomPreviewBar.js -src/components/views/rooms/SearchResultTile.js -src/components/views/settings/ChangeAvatar.js -src/components/views/settings/ChangePassword.js -src/components/views/settings/DevicesPanel.js -src/components/views/settings/Notifications.js -src/HtmlUtils.js src/ImageUtils.js src/Markdown.js -src/notifications/ContentRules.js -src/notifications/PushRuleVectorState.js -src/PlatformPeg.js -src/rageshake/rageshake.js -src/ratelimitedfunc.js src/Rooms.js src/Unread.js +src/Velociraptor.js +src/components/structures/RoomDirectory.js +src/components/structures/ScrollPanel.js +src/components/structures/UploadBar.js +src/components/views/elements/AddressSelector.js +src/components/views/elements/DirectorySearchBox.js +src/components/views/messages/MFileBody.js +src/components/views/messages/TextualBody.js +src/components/views/rooms/AuxPanel.js +src/components/views/rooms/LinkPreviewWidget.js +src/components/views/rooms/MemberList.js +src/components/views/rooms/RoomPreviewBar.js +src/components/views/settings/ChangeAvatar.js +src/components/views/settings/DevicesPanel.js +src/components/views/settings/Notifications.js +src/rageshake/rageshake.js +src/ratelimitedfunc.js +src/utils/DMRoomMap.js src/utils/DecryptFile.js src/utils/DirectoryUtils.js -src/utils/DMRoomMap.js -src/utils/FormattingUtils.js src/utils/MultiInviter.js src/utils/Receipt.js -src/Velociraptor.js test/components/structures/MessagePanel-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js test/mock-clock.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa9cc29f9..e4a7ddc407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,121 @@ +Changes in [3.5.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0) (2020-09-28) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0-rc.1...v3.5.0) + + * Upgrade JS SDK to 8.4.1 + +Changes in [3.5.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0-rc.1) (2020-09-23) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.1...v3.5.0-rc.1) + + * Upgrade JS SDK to 8.4.0-rc.1 + * Update from Weblate + [\#5246](https://github.com/matrix-org/matrix-react-sdk/pull/5246) + * Upgrade sanitize-html, set nesting limit + [\#5245](https://github.com/matrix-org/matrix-react-sdk/pull/5245) + * Add a note to use the desktop builds when seshat isn't available + [\#5225](https://github.com/matrix-org/matrix-react-sdk/pull/5225) + * Add some permission checks to the communities v2 prototype + [\#5240](https://github.com/matrix-org/matrix-react-sdk/pull/5240) + * Support HS-preferred Secure Backup setup methods + [\#5242](https://github.com/matrix-org/matrix-react-sdk/pull/5242) + * Only show User Info verify button if the other user has e2ee devices + [\#5234](https://github.com/matrix-org/matrix-react-sdk/pull/5234) + * Fix New Room List arrow key management + [\#5237](https://github.com/matrix-org/matrix-react-sdk/pull/5237) + * Fix Room Directory View & Preview actions for federated joins + [\#5235](https://github.com/matrix-org/matrix-react-sdk/pull/5235) + * Add a UI feature to disable advanced encryption options + [\#5238](https://github.com/matrix-org/matrix-react-sdk/pull/5238) + * UI Feature Flag: Communities + [\#5216](https://github.com/matrix-org/matrix-react-sdk/pull/5216) + * Rename apps back to widgets + [\#5236](https://github.com/matrix-org/matrix-react-sdk/pull/5236) + * Adjust layout and formatting of notifications / files cards + [\#5229](https://github.com/matrix-org/matrix-react-sdk/pull/5229) + * Fix Search Results Tile undefined variable access regression + [\#5232](https://github.com/matrix-org/matrix-react-sdk/pull/5232) + * Fix Cmd/Ctrl+Shift+U for File Upload + [\#5233](https://github.com/matrix-org/matrix-react-sdk/pull/5233) + * Disable the e2ee toggle when creating a room on a server with forced e2e + [\#5231](https://github.com/matrix-org/matrix-react-sdk/pull/5231) + * UI Feature Flag: Disable advanced options and tidy up some copy + [\#5215](https://github.com/matrix-org/matrix-react-sdk/pull/5215) + * UI Feature Flag: 3PIDs + [\#5228](https://github.com/matrix-org/matrix-react-sdk/pull/5228) + * Defer encryption setup until first E2EE room + [\#5219](https://github.com/matrix-org/matrix-react-sdk/pull/5219) + * Tidy devDeps, all the webpack stuff lives in the layer above + [\#5179](https://github.com/matrix-org/matrix-react-sdk/pull/5179) + * UI Feature Flag: Hide flair + [\#5214](https://github.com/matrix-org/matrix-react-sdk/pull/5214) + * UI Feature Flag: Identity server + [\#5218](https://github.com/matrix-org/matrix-react-sdk/pull/5218) + * UI Feature Flag: Share dialog QR code and social icons + [\#5221](https://github.com/matrix-org/matrix-react-sdk/pull/5221) + * UI Feature Flag: Registration, Password Reset, Deactivate + [\#5227](https://github.com/matrix-org/matrix-react-sdk/pull/5227) + * Retry joinRoom up to 5 times in the case of a 504 GATEWAY TIMEOUT + [\#5204](https://github.com/matrix-org/matrix-react-sdk/pull/5204) + * UI Feature Flag: Disable VoIP + [\#5217](https://github.com/matrix-org/matrix-react-sdk/pull/5217) + * Fix setState() usage in the constructor of RoomDirectory + [\#5224](https://github.com/matrix-org/matrix-react-sdk/pull/5224) + * Hide Analytics sections if piwik config is not provided + [\#5211](https://github.com/matrix-org/matrix-react-sdk/pull/5211) + * UI Feature Flag: Disable feedback button + [\#5213](https://github.com/matrix-org/matrix-react-sdk/pull/5213) + * Clean up UserInfo to not show a blank Power Selector for users not in room + [\#5220](https://github.com/matrix-org/matrix-react-sdk/pull/5220) + * Also hide bug reporting prompts from the Error Boundaries + [\#5212](https://github.com/matrix-org/matrix-react-sdk/pull/5212) + * Tactical improvements to 3PID invites + [\#5201](https://github.com/matrix-org/matrix-react-sdk/pull/5201) + * If no bug_report_endpoint_url, hide rageshaking from the App + [\#5210](https://github.com/matrix-org/matrix-react-sdk/pull/5210) + * Introduce a concept of UI features, using it for URL previews at first + [\#5208](https://github.com/matrix-org/matrix-react-sdk/pull/5208) + * Remove defunct "always show encryption icons" setting + [\#5207](https://github.com/matrix-org/matrix-react-sdk/pull/5207) + * Don't show Notifications Prompt Toast if user has master rule enabled + [\#5203](https://github.com/matrix-org/matrix-react-sdk/pull/5203) + * Fix Bridges tab crashing when the room does not have bridges + [\#5206](https://github.com/matrix-org/matrix-react-sdk/pull/5206) + * Don't count widgets which no longer exist towards pinned count + [\#5202](https://github.com/matrix-org/matrix-react-sdk/pull/5202) + * Fix crashes with cannot read isResizing of undefined + [\#5205](https://github.com/matrix-org/matrix-react-sdk/pull/5205) + * Prompt to remove the jitsi widget when pressing the call button + [\#5193](https://github.com/matrix-org/matrix-react-sdk/pull/5193) + * Show verification status in the room summary card + [\#5195](https://github.com/matrix-org/matrix-react-sdk/pull/5195) + * Fix user info scrolling in new card view + [\#5198](https://github.com/matrix-org/matrix-react-sdk/pull/5198) + * Fix sticker picker height + [\#5197](https://github.com/matrix-org/matrix-react-sdk/pull/5197) + * Call jitsi widgets 'group calls' + [\#5191](https://github.com/matrix-org/matrix-react-sdk/pull/5191) + * Don't show 'unpin' for persistent widgets + [\#5194](https://github.com/matrix-org/matrix-react-sdk/pull/5194) + * Split up cross-signing and secure backup settings + [\#5182](https://github.com/matrix-org/matrix-react-sdk/pull/5182) + * Fix onNewScreen to use replace when going from roomId->roomAlias + [\#5185](https://github.com/matrix-org/matrix-react-sdk/pull/5185) + * bring back 1.2M style badge counts rather than 99+ + [\#5192](https://github.com/matrix-org/matrix-react-sdk/pull/5192) + * Run the rageshake command through the bug report dialog + [\#5189](https://github.com/matrix-org/matrix-react-sdk/pull/5189) + * Account for via in pill matching regex + [\#5188](https://github.com/matrix-org/matrix-react-sdk/pull/5188) + * Remove now-unused create-react-class from lockfile + [\#5187](https://github.com/matrix-org/matrix-react-sdk/pull/5187) + * Fixed 1px jump upwards + [\#5163](https://github.com/matrix-org/matrix-react-sdk/pull/5163) + * Always allow widgets when using the local version + [\#5184](https://github.com/matrix-org/matrix-react-sdk/pull/5184) + * Migrate RoomView and RoomContext to Typescript + [\#5175](https://github.com/matrix-org/matrix-react-sdk/pull/5175) + Changes in [3.4.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.1) (2020-09-14) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.0...v3.4.1) diff --git a/README.md b/README.md index e468d272d0..4db02418ba 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,8 @@ yarn link matrix-js-sdk yarn install ``` -See the [help for `yarn link`](https://yarnpkg.com/docs/cli/link) for more -details about this. +See the [help for `yarn link`](https://classic.yarnpkg.com/docs/cli/link) for +more details about this. Running tests ============= diff --git a/__mocks__/browser-request.js b/__mocks__/browser-request.js index 7d231fb9db..4c59e8a43a 100644 --- a/__mocks__/browser-request.js +++ b/__mocks__/browser-request.js @@ -1,5 +1,10 @@ const en = require("../src/i18n/strings/en_EN"); +const de = require("../src/i18n/strings/de_DE"); +// Mock the browser-request for the languageHandler tests to return +// Fake languages.json containing references to en_EN and de_DE +// en_EN.json +// de_DE.json module.exports = jest.fn((opts, cb) => { const url = opts.url || opts.uri; if (url && url.endsWith("languages.json")) { @@ -8,9 +13,15 @@ module.exports = jest.fn((opts, cb) => { "fileName": "en_EN.json", "label": "English", }, + "de": { + "fileName": "de_DE.json", + "label": "German", + }, })); } else if (url && url.endsWith("en_EN.json")) { cb(undefined, {status: 200}, JSON.stringify(en)); + } else if (url && url.endsWith("de_DE.json")) { + cb(undefined, {status: 200}, JSON.stringify(de)); } else { cb(true, {status: 404}, ""); } diff --git a/__test-utils__/environment.js b/__test-utils__/environment.js new file mode 100644 index 0000000000..9870c133a2 --- /dev/null +++ b/__test-utils__/environment.js @@ -0,0 +1,17 @@ +const BaseEnvironment = require("jest-environment-jsdom-sixteen"); + +class Environment extends BaseEnvironment { + constructor(config, options) { + super(Object.assign({}, config, { + globals: Object.assign({}, config.globals, { + // Explicitly specify the correct globals to workaround Jest bug + // https://github.com/facebook/jest/issues/7780 + Uint32Array: Uint32Array, + Uint8Array: Uint8Array, + ArrayBuffer: ArrayBuffer, + }), + }), options); + } +} + +module.exports = Environment; diff --git a/package.json b/package.json index 7aa3df136b..0a3fd7a8b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.4.1", + "version": "3.5.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -80,6 +80,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-widget-api": "^0.1.0-beta.3", "minimist": "^1.2.5", "pako": "^1.0.11", "parse5": "^5.1.1", @@ -96,7 +97,7 @@ "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", - "sanitize-html": "^1.27.1", + "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", @@ -121,7 +122,7 @@ "@babel/preset-typescript": "^7.10.4", "@babel/register": "^7.10.5", "@babel/traverse": "^7.11.0", - "@peculiar/webcrypto": "^1.1.2", + "@peculiar/webcrypto": "^1.1.3", "@types/classnames": "^2.2.10", "@types/counterpart": "^0.18.1", "@types/flux": "^3.1.9", @@ -151,8 +152,9 @@ "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^2.5.1", "glob": "^5.0.15", - "jest": "^24.9.0", - "jest-canvas-mock": "^2.2.0", + "jest": "^26.5.2", + "jest-canvas-mock": "^2.3.0", + "jest-environment-jsdom-sixteen": "^1.0.3", "lolex": "^5.1.2", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", @@ -165,6 +167,7 @@ "walk": "^2.3.14" }, "jest": { + "testEnvironment": "./__test-utils__/environment.js", "testMatch": [ "/test/**/*-test.js" ], diff --git a/release.sh b/release.sh index 23b8822041..e2cefcbe74 100755 --- a/release.sh +++ b/release.sh @@ -9,6 +9,9 @@ set -e cd `dirname $0` +# This link seems to get eaten by the release process, so ensure it exists. +yarn link matrix-js-sdk + for i in matrix-js-sdk do echo "Checking version of $i..." diff --git a/res/css/_common.scss b/res/css/_common.scss index a22d77f3d3..3346394edd 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -18,6 +18,8 @@ limitations under the License. @import "./_font-sizes.scss"; +$hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic + :root { font-size: 10px; } @@ -260,7 +262,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { font-weight: 300; font-size: $font-15px; position: relative; - padding: 25px 30px 30px 30px; + padding: 24px; max-height: 80%; box-shadow: 2px 15px 30px 0 $dialog-shadow-color; border-radius: 8px; diff --git a/res/css/_components.scss b/res/css/_components.scss index 35b4c1b965..06cdbdcb4b 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -81,8 +81,6 @@ @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @import "./views/dialogs/_ServerOfflineDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; -@import "./views/dialogs/_SetMxIdDialog.scss"; -@import "./views/dialogs/_SetPasswordDialog.scss"; @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; @@ -101,6 +99,7 @@ @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressTile.scss"; +@import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @@ -140,6 +139,7 @@ @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index e0814182f5..29e6fecd34 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -133,6 +133,10 @@ limitations under the License. .mx_RoomDirectory_topic { cursor: initial; color: $light-fg-color; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; } .mx_RoomDirectory_alias { diff --git a/res/css/structures/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 4a4bb125a3..39a8ebed32 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_TabbedView { margin: 0; - padding: 0 0 0 58px; + padding: 0 0 0 16px; display: flex; flex-direction: column; position: absolute; @@ -25,6 +25,7 @@ limitations under the License. bottom: 0; left: 0; right: 0; + margin-top: 8px; } .mx_TabbedView_tabLabels { @@ -35,13 +36,13 @@ limitations under the License. } .mx_TabbedView_tabLabel { + display: flex; + align-items: center; vertical-align: text-top; cursor: pointer; - display: block; - border-radius: 3px; - font-size: $font-14px; - min-height: 24px; // use min-height instead of height to allow the label to overflow a bit - margin-bottom: 6px; + padding: 8px 0; + border-radius: 8px; + font-size: $font-13px; position: relative; } @@ -51,9 +52,8 @@ limitations under the License. } .mx_TabbedView_maskedIcon { - margin-left: 6px; - margin-right: 9px; - margin-top: 1px; + margin-left: 8px; + margin-right: 16px; width: 16px; height: 16px; display: inline-block; @@ -65,10 +65,9 @@ limitations under the License. mask-repeat: no-repeat; mask-size: 16px; width: 16px; - height: 22px; + height: 16px; mask-position: center; content: ''; - vertical-align: middle; } .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index d4199a1e66..9bcde6e1e0 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -48,7 +48,6 @@ limitations under the License. white-space: nowrap; overflow: hidden; margin: 0 auto; - padding-left: 40px; padding-right: 80px; } diff --git a/res/css/views/dialogs/_SetMxIdDialog.scss b/res/css/views/dialogs/_SetMxIdDialog.scss deleted file mode 100644 index 1df34f3408..0000000000 --- a/res/css/views/dialogs/_SetMxIdDialog.scss +++ /dev/null @@ -1,50 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_SetMxIdDialog .mx_Dialog_title { - padding-right: 40px; -} - -.mx_SetMxIdDialog_input_group { - display: flex; -} - -.mx_SetMxIdDialog_input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; - font-size: $font-15px; - width: 100%; - max-width: 280px; -} - -.mx_SetMxIdDialog_input.error, -.mx_SetMxIdDialog_input.error:focus { - border: 1px solid $warning-color; -} - -.mx_SetMxIdDialog_input_group .mx_Spinner { - height: 37px; - padding-left: 10px; - justify-content: flex-start; -} - -.mx_SetMxIdDialog .success { - color: $accent-color; -} diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index ec813a1a07..6c4ed35c5a 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -36,7 +36,6 @@ limitations under the License. } .mx_Dialog_title { - text-align: center; margin-bottom: 24px; } } diff --git a/res/css/views/dialogs/_SetPasswordDialog.scss b/res/css/views/elements/_DesktopBuildsNotice.scss similarity index 54% rename from res/css/views/dialogs/_SetPasswordDialog.scss rename to res/css/views/elements/_DesktopBuildsNotice.scss index 1f99353298..3672595bf1 100644 --- a/res/css/views/dialogs/_SetPasswordDialog.scss +++ b/res/css/views/elements/_DesktopBuildsNotice.scss @@ -1,5 +1,5 @@ /* -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. @@ -14,21 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SetPasswordDialog_change_password input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; - font-size: $font-15px; - max-width: 280px; - margin-bottom: 10px; -} +.mx_DesktopBuildsNotice { + text-align: center; + padding: 0 16px; -.mx_SetPasswordDialog_change_password_button { - margin-top: 68px; -} + > * { + vertical-align: middle; + } -.mx_SetPasswordDialog .mx_Dialog_content { - margin-bottom: 0px; + > img { + margin-right: 8px; + } } diff --git a/res/css/views/messages/_MJitsiWidgetEvent.scss b/res/css/views/messages/_MJitsiWidgetEvent.scss new file mode 100644 index 0000000000..3e51e89744 --- /dev/null +++ b/res/css/views/messages/_MJitsiWidgetEvent.scss @@ -0,0 +1,55 @@ +/* +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. +*/ + +.mx_MJitsiWidgetEvent { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) min-content; + + &::before { + grid-column: 1; + grid-row: 1 / 3; + width: 16px; + height: 16px; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: $composer-e2e-icon-color; // XXX: Variable abuse + margin-top: 4px; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + + .mx_MJitsiWidgetEvent_title { + font-weight: 600; + font-size: $font-15px; + grid-column: 2; + grid-row: 1; + } + + .mx_MJitsiWidgetEvent_subtitle { + grid-column: 2; + grid-row: 2; + } + + .mx_MJitsiWidgetEvent_title, + .mx_MJitsiWidgetEvent_subtitle { + overflow-wrap: break-word; + } +} diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index 26f846fe0a..3ff3b52531 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -40,6 +40,7 @@ limitations under the License. width: 20px; margin: 12px; top: 0; + border-radius: 10px; &::before { content: ""; @@ -55,7 +56,6 @@ limitations under the License. } .mx_BaseCard_back { - border-radius: 4px; left: 0; &::before { @@ -66,7 +66,6 @@ limitations under the License. } .mx_BaseCard_close { - border-radius: 10px; right: 0; &::before { diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index fee3d61153..451704bd88 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -$MiniAppTileHeight: 114px; +$MiniAppTileHeight: 200px; .mx_AppsDrawer { margin: 5px 5px 5px 18px; @@ -78,10 +78,6 @@ $MiniAppTileHeight: 114px; font-size: $font-12px; } -.mx_AddWidget_button_full_width { - max-width: 960px; -} - .mx_SetAppURLDialog_input { border-radius: 3px; border: 1px solid $input-border-color; @@ -92,7 +88,6 @@ $MiniAppTileHeight: 114px; } .mx_AppTile { - max-width: 960px; width: 50%; border: 5px solid $widget-menu-bar-bg-color; border-radius: 4px; @@ -105,7 +100,6 @@ $MiniAppTileHeight: 114px; } .mx_AppTileFullWidth { - max-width: 960px; width: 100%; margin: 0; padding: 0; @@ -116,7 +110,6 @@ $MiniAppTileHeight: 114px; } .mx_AppTile_mini { - max-width: 960px; width: 100%; margin: 0; padding: 0; @@ -220,9 +213,10 @@ $MiniAppTileHeight: 114px; } .mx_AppTileBody_mini { - height: 112px; + height: $MiniAppTileHeight; width: 100%; overflow: hidden; + border-radius: 8px; } .mx_AppTile .mx_AppTileBody, diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 2366667c95..f00907aeef 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -96,11 +96,21 @@ limitations under the License. } .mx_MemberList_invite span { - background-image: url('$(res)/img/element-icons/room/invite.svg'); - background-repeat: no-repeat; - background-position: center left; - background-size: 20px; - padding: 8px 0 8px 25px; + padding: 8px 0; + display: inline-flex; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 20px; + width: 20px; + height: 20px; + margin-right: 5px; + } } .mx_MemberList_inviteCommunity span { diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index a403a8dc4c..71c0db947e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -217,7 +217,7 @@ limitations under the License. } } - &.mx_MessageComposer_hangup::before { + &.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before { background-color: $warning-color; } } diff --git a/res/css/views/rooms/_SearchBar.scss b/res/css/views/rooms/_SearchBar.scss index fecc8d78d8..d9f730a8b6 100644 --- a/res/css/views/rooms/_SearchBar.scss +++ b/res/css/views/rooms/_SearchBar.scss @@ -68,3 +68,4 @@ limitations under the License. cursor: pointer; } } + diff --git a/res/css/views/settings/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss index eddcf9f55a..4a1f57a00e 100644 --- a/res/css/views/settings/_AvatarSetting.scss +++ b/res/css/views/settings/_AvatarSetting.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,13 +15,56 @@ limitations under the License. */ .mx_AvatarSetting_avatar { - width: $font-88px; - height: $font-88px; - margin-left: 13px; + width: 90px; + min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words + height: 90px; + margin-top: 8px; position: relative; + .mx_AvatarSetting_hover { + transition: opacity $hover-transition; + + // position to place the hover bg over the entire thing + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + pointer-events: none; // let the pointer fall through the underlying thing + + line-height: 90px; + text-align: center; + + > span { + color: #fff; // hardcoded to contrast with background + position: relative; // tricks the layout engine into putting this on top of the bg + font-weight: 500; + } + + .mx_AvatarSetting_hoverBg { + // absolute position to lazily fill the entire container + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + opacity: 0.5; + background-color: $settings-profile-overlay-placeholder-fg-color; + border-radius: 90px; + } + } + + &.mx_AvatarSetting_avatar_hovering .mx_AvatarSetting_hover { + opacity: 1; + } + + &:not(.mx_AvatarSetting_avatar_hovering) .mx_AvatarSetting_hover { + opacity: 0; + } + & > * { - width: $font-88px; box-sizing: border-box; } @@ -30,7 +73,7 @@ limitations under the License. } .mx_AccessibleButton.mx_AccessibleButton_kind_link_sm { - color: $button-danger-bg-color; + width: 100%; } & > img { @@ -41,8 +84,9 @@ limitations under the License. & > img, .mx_AvatarSetting_avatarPlaceholder { display: block; - height: $font-88px; - border-radius: 4px; + height: 90px; + border-radius: 90px; + cursor: pointer; } .mx_AvatarSetting_avatarPlaceholder::before { @@ -58,6 +102,29 @@ limitations under the License. left: 0; right: 0; } + + .mx_AvatarSetting_uploadButton { + width: 32px; + height: 32px; + border-radius: 32px; + background-color: $settings-profile-button-bg-color; + + position: absolute; + bottom: 0; + right: 0; + } + + .mx_AvatarSetting_uploadButton::before { + content: ""; + display: block; + width: 100%; + height: 100%; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 55%; + background-color: $settings-profile-button-fg-color; + mask-image: url('$(res)/img/feather-customised/edit.svg'); + } } .mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder { diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 58624d1597..732cbedf02 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,6 +20,13 @@ limitations under the License. .mx_ProfileSettings_controls { flex-grow: 1; + margin-right: 54px; + + // We put the header under the controls with some minor styling to cheat + // alignment of the field with the avatar + .mx_SettingsTab_subheading { + margin-top: 0; + } } .mx_ProfileSettings_controls .mx_Field #profileTopic { @@ -41,3 +48,17 @@ limitations under the License. .mx_ProfileSettings_avatarUpload { display: none; } + +.mx_ProfileSettings_profileForm { + @mixin mx_Settings_fullWidthField; + border-bottom: 1px solid $menu-border-color; +} + +.mx_ProfileSettings_buttons { + margin-top: 10px; // 18px is already accounted for by the

above the buttons + margin-bottom: 28px; + + > .mx_AccessibleButton_kind_link { + padding-left: 0; // to align with left side + } +} diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss index 6c9b89cf5a..8b73e69031 100644 --- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss @@ -22,6 +22,13 @@ limitations under the License. margin-top: 0; } +// TODO: Make this selector less painful +.mx_GeneralUserSettingsTab_accountSection .mx_SettingsTab_subheading:nth-child(n + 1), +.mx_GeneralUserSettingsTab_discovery .mx_SettingsTab_subheading:nth-child(n + 2), +.mx_SetIdServer .mx_SettingsTab_subheading { + margin-top: 24px; +} + .mx_GeneralUserSettingsTab_accountSection .mx_Spinner, .mx_GeneralUserSettingsTab_discovery .mx_Spinner { // Move the spinner to the left side of the container (default center) diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 4d26d8a312..759797ae7b 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -23,9 +23,16 @@ limitations under the License. z-index: 100; box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); - cursor: pointer; + // Disable pointer events for Jitsi widgets to function. Direct + // calls have their own cursor and behaviour, but we need to make + // sure the cursor hits the iframe for Jitsi which will be at a + // different level. + pointer-events: none; .mx_CallPreview { + pointer-events: initial; // restore pointer events so the user can leave/interact + cursor: pointer; + .mx_VideoView { width: 350px; } @@ -37,7 +44,7 @@ limitations under the License. } .mx_AppTile_persistedWrapper div { - min-width: 300px; + min-width: 350px; } .mx_IncomingCallBox { @@ -45,11 +52,14 @@ limitations under the License. background-color: $primary-bg-color; padding: 8px; + pointer-events: initial; // restore pointer events so the user can accept/decline + cursor: pointer; + .mx_IncomingCallBox_CallerInfo { display: flex; direction: row; - img { + img, .mx_BaseAvatar_initial { margin: 8px; } diff --git a/res/img/element-desktop-logo.svg b/res/img/element-desktop-logo.svg new file mode 100644 index 0000000000..2031733ce3 --- /dev/null +++ b/res/img/element-desktop-logo.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/img/element-icons/room/invite.svg b/res/img/element-icons/room/invite.svg index f713e57d73..655f9f118a 100644 --- a/res/img/element-icons/room/invite.svg +++ b/res/img/element-icons/room/invite.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index a3b03c777e..331b5f4692 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -87,11 +87,10 @@ $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; +$settings-profile-placeholder-bg-color: #21262c; $settings-profile-overlay-placeholder-fg-color: #454545; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: $text-secondary-color; $topleftmenu-color: $text-primary-color; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 2741dcebf8..14ce264bc0 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -86,10 +86,9 @@ $lightbox-background-bg-color: #000; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; $settings-profile-overlay-placeholder-fg-color: #454545; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: $text-secondary-color; $topleftmenu-color: $text-primary-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 4fd2a3615b..b030fb7423 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -144,10 +144,9 @@ $blockquote-fg-color: #777; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; $settings-profile-overlay-placeholder-fg-color: #2e2f32; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: #61708b; $voip-decline-color: #f48080; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index b830e86e02..6bb46e8a67 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -124,15 +124,15 @@ $pinned-unread-color: var(--warning-color); $warning-color: var(--warning-color); $button-danger-disabled-bg-color: var(--warning-color-50pct); // still needs alpha at 0.5 // -// --username colors -$username-variant1-color: var(--username-colors_1, $username-variant1-color); -$username-variant2-color: var(--username-colors_2, $username-variant2-color); -$username-variant3-color: var(--username-colors_3, $username-variant3-color); -$username-variant4-color: var(--username-colors_4, $username-variant4-color); -$username-variant5-color: var(--username-colors_5, $username-variant5-color); -$username-variant6-color: var(--username-colors_6, $username-variant6-color); -$username-variant7-color: var(--username-colors_7, $username-variant7-color); -$username-variant8-color: var(--username-colors_8, $username-variant8-color); +// --username colors (which use a 0-based index) +$username-variant1-color: var(--username-colors_0, $username-variant1-color); +$username-variant2-color: var(--username-colors_1, $username-variant2-color); +$username-variant3-color: var(--username-colors_2, $username-variant3-color); +$username-variant4-color: var(--username-colors_3, $username-variant4-color); +$username-variant5-color: var(--username-colors_4, $username-variant5-color); +$username-variant6-color: var(--username-colors_5, $username-variant6-color); +$username-variant7-color: var(--username-colors_6, $username-variant7-color); +$username-variant8-color: var(--username-colors_7, $username-variant8-color); // // --timeline-highlights-color $event-selected-color: var(--timeline-highlights-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 05302a2a80..140783212d 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -137,11 +137,10 @@ $blockquote-bar-color: #ddd; $blockquote-fg-color: #777; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #e7e7e7; -$settings-profile-overlay-bg-color: #000; -$settings-profile-overlay-placeholder-bg-color: transparent; -$settings-profile-overlay-fg-color: #fff; +$settings-profile-placeholder-bg-color: #f4f6fa; $settings-profile-overlay-placeholder-fg-color: #2e2f32; +$settings-profile-button-bg-color: #e7e7e7; +$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; $settings-subsection-fg-color: #61708b; $voip-decline-color: #f48080; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index e1111a8a94..91b91de90d 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -30,6 +30,7 @@ import {Notifier} from "../Notifier"; import type {Renderer} from "react-dom"; import RightPanelStore from "../stores/RightPanelStore"; import WidgetStore from "../stores/WidgetStore"; +import CallHandler from "../CallHandler"; declare global { interface Window { @@ -53,6 +54,7 @@ declare global { mxNotifier: typeof Notifier; mxRightPanelStore: RightPanelStore; mxWidgetStore: WidgetStore; + mxCallHandler: CallHandler; } interface Document { @@ -62,6 +64,9 @@ declare global { interface Navigator { userLanguage?: string; + // https://github.com/Microsoft/TypeScript/issues/19473 + // https://developer.mozilla.org/en-US/docs/Web/API/MediaSession + mediaSession: any; } interface StorageEstimate { diff --git a/src/@types/sanitize-html.ts b/src/@types/sanitize-html.ts new file mode 100644 index 0000000000..4cada29845 --- /dev/null +++ b/src/@types/sanitize-html.ts @@ -0,0 +1,23 @@ +/* +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 sanitizeHtml from 'sanitize-html'; + +export interface IExtendedSanitizeOptions extends sanitizeHtml.IOptions { + // This option only exists in 2.x RCs so far, so not yet present in the + // separate type definition module. + nestingLimit?: number; +} diff --git a/src/Avatar.js b/src/Avatar.js index d76ea6f2c4..1c1182b98d 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -82,6 +82,7 @@ function urlForColor(color) { const colorToDataURLCache = new Map(); export function defaultAvatarUrlForString(s) { + if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8']; let total = 0; for (let i = 0; i < s.length; ++i) { diff --git a/src/CallHandler.js b/src/CallHandler.js deleted file mode 100644 index ad40332af5..0000000000 --- a/src/CallHandler.js +++ /dev/null @@ -1,526 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/* - * Manages a list of all the currently active calls. - * - * This handler dispatches when voip calls are added/updated/removed from this list: - * { - * action: 'call_state' - * room_id: - * } - * - * To know the state of the call, this handler exposes a getter to - * obtain the call for a room: - * var call = CallHandler.getCall(roomId) - * var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing - * - * This handler listens for and handles the following actions: - * { - * action: 'place_call', - * type: 'voice|video', - * room_id: - * } - * - * { - * action: 'incoming_call' - * call: MatrixCall - * } - * - * { - * action: 'hangup' - * room_id: - * } - * - * { - * action: 'answer' - * room_id: - * } - */ - -import {MatrixClientPeg} from './MatrixClientPeg'; -import PlatformPeg from './PlatformPeg'; -import Modal from './Modal'; -import { _t } from './languageHandler'; -import Matrix from 'matrix-js-sdk'; -import dis from './dispatcher/dispatcher'; -import WidgetUtils from './utils/WidgetUtils'; -import WidgetEchoStore from './stores/WidgetEchoStore'; -import SettingsStore from './settings/SettingsStore'; -import {generateHumanReadableId} from "./utils/NamingUtils"; -import {Jitsi} from "./widgets/Jitsi"; -import {WidgetType} from "./widgets/WidgetType"; -import {SettingLevel} from "./settings/SettingLevel"; -import {base32} from "rfc4648"; - -import QuestionDialog from "./components/views/dialogs/QuestionDialog"; -import ErrorDialog from "./components/views/dialogs/ErrorDialog"; - -global.mxCalls = { - //room_id: MatrixCall -}; -const calls = global.mxCalls; -let ConferenceHandler = null; - -const audioPromises = {}; - -function play(audioId) { - // TODO: Attach an invisible element for this instead - // which listens? - const audio = document.getElementById(audioId); - if (audio) { - const playAudio = async () => { - try { - // This still causes the chrome debugger to break on promise rejection if - // the promise is rejected, even though we're catching the exception. - await audio.play(); - } catch (e) { - // This is usually because the user hasn't interacted with the document, - // or chrome doesn't think so and is denying the request. Not sure what - // we can really do here... - // https://github.com/vector-im/element-web/issues/7657 - console.log("Unable to play audio clip", e); - } - }; - if (audioPromises[audioId]) { - audioPromises[audioId] = audioPromises[audioId].then(()=>{ - audio.load(); - return playAudio(); - }); - } else { - audioPromises[audioId] = playAudio(); - } - } -} - -function pause(audioId) { - // TODO: Attach an invisible element for this instead - // which listens? - const audio = document.getElementById(audioId); - if (audio) { - if (audioPromises[audioId]) { - audioPromises[audioId] = audioPromises[audioId].then(()=>audio.pause()); - } else { - // pause doesn't actually return a promise, but might as well do this for symmetry with play(); - audioPromises[audioId] = audio.pause(); - } - } -} - -function _setCallListeners(call) { - call.on("error", function(err) { - console.error("Call error:", err); - if ( - MatrixClientPeg.get().getTurnServers().length === 0 && - SettingsStore.getValue("fallbackICEServerAllowed") === null - ) { - _showICEFallbackPrompt(); - return; - } - - Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { - title: _t('Call Failed'), - description: err.message, - }); - }); - call.on("hangup", function() { - _setCallState(undefined, call.roomId, "ended"); - }); - // map web rtc states to dummy UI state - // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing - call.on("state", function(newState, oldState) { - if (newState === "ringing") { - _setCallState(call, call.roomId, "ringing"); - pause("ringbackAudio"); - } else if (newState === "invite_sent") { - _setCallState(call, call.roomId, "ringback"); - play("ringbackAudio"); - } else if (newState === "ended" && oldState === "connected") { - _setCallState(undefined, call.roomId, "ended"); - pause("ringbackAudio"); - play("callendAudio"); - } else if (newState === "ended" && oldState === "invite_sent" && - (call.hangupParty === "remote" || - (call.hangupParty === "local" && call.hangupReason === "invite_timeout") - )) { - _setCallState(call, call.roomId, "busy"); - pause("ringbackAudio"); - play("busyAudio"); - Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, { - title: _t('Call Timeout'), - description: _t('The remote side failed to pick up') + '.', - }); - } else if (oldState === "invite_sent") { - _setCallState(call, call.roomId, "stop_ringback"); - pause("ringbackAudio"); - } else if (oldState === "ringing") { - _setCallState(call, call.roomId, "stop_ringing"); - pause("ringbackAudio"); - } else if (newState === "connected") { - _setCallState(call, call.roomId, "connected"); - pause("ringbackAudio"); - } - }); -} - -function _setCallState(call, roomId, status) { - console.log( - `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`, - ); - calls[roomId] = call; - - if (status === "ringing") { - play("ringAudio"); - } else if (call && call.call_state === "ringing") { - pause("ringAudio"); - } - - if (call) { - call.call_state = status; - } - dis.dispatch({ - action: 'call_state', - room_id: roomId, - state: status, - }); -} - -function _showICEFallbackPrompt() { - const cli = MatrixClientPeg.get(); - const code = sub => {sub}; - Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { - title: _t("Call failed due to misconfigured server"), - description:

-

{_t( - "Please ask the administrator of your homeserver " + - "(%(homeserverDomain)s) to configure a TURN server in " + - "order for calls to work reliably.", - { homeserverDomain: cli.getDomain() }, { code }, - )}

-

{_t( - "Alternatively, you can try to use the public server at " + - "turn.matrix.org, but this will not be as reliable, and " + - "it will share your IP address with that server. You can also manage " + - "this in Settings.", - null, { code }, - )}

-
, - button: _t('Try using turn.matrix.org'), - cancelButton: _t('OK'), - onFinished: (allow) => { - SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); - cli.setFallbackICEServerAllowed(allow); - }, - }, null, true); -} - -function _onAction(payload) { - function placeCall(newCall) { - _setCallListeners(newCall); - if (payload.type === 'voice') { - newCall.placeVoiceCall(); - } else if (payload.type === 'video') { - newCall.placeVideoCall( - payload.remote_element, - payload.local_element, - ); - } else if (payload.type === 'screensharing') { - const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); - if (screenCapErrorString) { - _setCallState(undefined, newCall.roomId, "ended"); - console.log("Can't capture screen: " + screenCapErrorString); - Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { - title: _t('Unable to capture screen'), - description: screenCapErrorString, - }); - return; - } - newCall.placeScreenSharingCall( - payload.remote_element, - payload.local_element, - ); - } else { - console.error("Unknown conf call type: %s", payload.type); - } - } - - switch (payload.action) { - case 'place_call': - { - if (callHandler.getAnyActiveCall()) { - Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { - title: _t('Existing Call'), - description: _t('You are already in a call.'), - }); - return; // don't allow >1 call to be placed. - } - - // if the runtime env doesn't do VoIP, whine. - if (!MatrixClientPeg.get().supportsVoip()) { - Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { - title: _t('VoIP is unsupported'), - description: _t('You cannot place VoIP calls in this browser.'), - }); - return; - } - - const room = MatrixClientPeg.get().getRoom(payload.room_id); - if (!room) { - console.error("Room %s does not exist.", payload.room_id); - return; - } - - const members = room.getJoinedMembers(); - if (members.length <= 1) { - Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { - description: _t('You cannot place a call with yourself.'), - }); - return; - } else if (members.length === 2) { - console.info("Place %s call in %s", payload.type, payload.room_id); - const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); - placeCall(call); - } else { // > 2 - dis.dispatch({ - action: "place_conference_call", - room_id: payload.room_id, - type: payload.type, - remote_element: payload.remote_element, - local_element: payload.local_element, - }); - } - } - break; - case 'place_conference_call': - console.info("Place conference call in %s", payload.room_id); - _startCallApp(payload.room_id, payload.type); - break; - case 'incoming_call': - { - if (callHandler.getAnyActiveCall()) { - // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. - // we avoid rejecting with "busy" in case the user wants to answer it on a different device. - // in future we could signal a "local busy" as a warning to the caller. - // see https://github.com/vector-im/vector-web/issues/1964 - return; - } - - // if the runtime env doesn't do VoIP, stop here. - if (!MatrixClientPeg.get().supportsVoip()) { - return; - } - - const call = payload.call; - _setCallListeners(call); - _setCallState(call, call.roomId, "ringing"); - } - break; - case 'hangup': - if (!calls[payload.room_id]) { - return; // no call to hangup - } - calls[payload.room_id].hangup(); - _setCallState(null, payload.room_id, "ended"); - break; - case 'answer': - if (!calls[payload.room_id]) { - return; // no call to answer - } - calls[payload.room_id].answer(); - _setCallState(calls[payload.room_id], payload.room_id, "connected"); - dis.dispatch({ - action: "view_room", - room_id: payload.room_id, - }); - break; - } -} - -async function _startCallApp(roomId, type) { - dis.dispatch({ - action: 'appsDrawer', - show: true, - }); - - const room = MatrixClientPeg.get().getRoom(roomId); - const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); - - if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) { - Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t('A call is currently being placed!'), - }); - return; - } - - if (currentJitsiWidgets.length > 0) { - console.warn( - "Refusing to start conference call widget in " + roomId + - " a conference call widget is already present", - ); - - if (WidgetUtils.canUserModifyWidgets(roomId)) { - Modal.createTrackedDialog('Already have Jitsi Widget', '', QuestionDialog, { - title: _t('End Call'), - description: _t('Remove the group call from the room?'), - button: _t('End Call'), - cancelButton: _t('Cancel'), - onFinished: (endCall) => { - if (endCall) { - WidgetUtils.setRoomWidget(roomId, currentJitsiWidgets[0].getContent()['id']); - } - }, - }); - } else { - Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t("You don't have permission to remove the call from the room"), - }); - } - return; - } - - const jitsiDomain = Jitsi.getInstance().preferredDomain; - const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); - let confId; - if (jitsiAuth === 'openidtoken-jwt') { - // Create conference ID from room ID - // For compatibility with Jitsi, use base32 without padding. - // More details here: - // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification - confId = base32.stringify(Buffer.from(roomId), { pad: false }); - } else { - // Create a random human readable conference ID - confId = `JitsiConference${generateHumanReadableId()}`; - } - - let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); - - // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets - const parsedUrl = new URL(widgetUrl); - parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead - parsedUrl.searchParams.set('confId', confId); - widgetUrl = parsedUrl.toString(); - - const widgetData = { - conferenceId: confId, - isAudioOnly: type === 'voice', - domain: jitsiDomain, - auth: jitsiAuth, - }; - - const widgetId = ( - 'jitsi_' + - MatrixClientPeg.get().credentials.userId + - '_' + - Date.now() - ); - - WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => { - console.log('Jitsi widget added'); - }).catch((e) => { - if (e.errcode === 'M_FORBIDDEN') { - Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { - title: _t('Permission Required'), - description: _t("You do not have permission to start a conference call in this room"), - }); - } - console.error(e); - }); -} - -// FIXME: Nasty way of making sure we only register -// with the dispatcher once -if (!global.mxCallHandler) { - dis.register(_onAction); - // add empty handlers for media actions, otherwise the media keys - // end up causing the audio elements with our ring/ringback etc - // audio clips in to play. - if (navigator.mediaSession) { - navigator.mediaSession.setActionHandler('play', function() {}); - navigator.mediaSession.setActionHandler('pause', function() {}); - navigator.mediaSession.setActionHandler('seekbackward', function() {}); - navigator.mediaSession.setActionHandler('seekforward', function() {}); - navigator.mediaSession.setActionHandler('previoustrack', function() {}); - navigator.mediaSession.setActionHandler('nexttrack', function() {}); - } -} - -const callHandler = { - getCallForRoom: function(roomId) { - let call = callHandler.getCall(roomId); - if (call) return call; - - if (ConferenceHandler) { - call = ConferenceHandler.getConferenceCallForRoom(roomId); - } - if (call) return call; - - return null; - }, - - getCall: function(roomId) { - return calls[roomId] || null; - }, - - getAnyActiveCall: function() { - const roomsWithCalls = Object.keys(calls); - for (let i = 0; i < roomsWithCalls.length; i++) { - if (calls[roomsWithCalls[i]] && - calls[roomsWithCalls[i]].call_state !== "ended") { - return calls[roomsWithCalls[i]]; - } - } - return null; - }, - - /** - * The conference handler is a module that deals with implementation-specific - * multi-party calling implementations. Element passes in its own which creates - * a one-to-one call with a freeswitch conference bridge. As of July 2018, - * the de-facto way of conference calling is a Jitsi widget, so this is - * deprecated. It reamins here for two reasons: - * 1. So Element still supports joining existing freeswitch conference calls - * (but doesn't support creating them). After a transition period, we can - * remove support for joining them too. - * 2. To hide the one-to-one rooms that old-style conferencing creates. This - * is much harder to remove: probably either we make Element leave & forget these - * rooms after we remove support for joining freeswitch conferences, or we - * accept that random rooms with cryptic users will suddently appear for - * anyone who's ever used conference calling, or we are stuck with this - * code forever. - * - * @param {object} confHandler The conference handler object - */ - setConferenceHandler: function(confHandler) { - ConferenceHandler = confHandler; - }, - - getConferenceHandler: function() { - return ConferenceHandler; - }, -}; -// Only things in here which actually need to be global are the -// calls list (done separately) and making sure we only register -// with the dispatcher once (which uses this mechanism but checks -// separately). This could be tidied up. -if (global.mxCallHandler === undefined) { - global.mxCallHandler = callHandler; -} - -export default global.mxCallHandler; diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx new file mode 100644 index 0000000000..5b368016b6 --- /dev/null +++ b/src/CallHandler.tsx @@ -0,0 +1,513 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017, 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* + * Manages a list of all the currently active calls. + * + * This handler dispatches when voip calls are added/updated/removed from this list: + * { + * action: 'call_state' + * room_id: + * } + * + * To know the state of the call, this handler exposes a getter to + * obtain the call for a room: + * var call = CallHandler.getCall(roomId) + * var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing + * + * This handler listens for and handles the following actions: + * { + * action: 'place_call', + * type: 'voice|video', + * room_id: + * } + * + * { + * action: 'incoming_call' + * call: MatrixCall + * } + * + * { + * action: 'hangup' + * room_id: + * } + * + * { + * action: 'answer' + * room_id: + * } + */ + +import React from 'react'; + +import {MatrixClientPeg} from './MatrixClientPeg'; +import PlatformPeg from './PlatformPeg'; +import Modal from './Modal'; +import { _t } from './languageHandler'; +// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising +import Matrix from 'matrix-js-sdk'; +import dis from './dispatcher/dispatcher'; +import WidgetUtils from './utils/WidgetUtils'; +import WidgetEchoStore from './stores/WidgetEchoStore'; +import SettingsStore from './settings/SettingsStore'; +import {generateHumanReadableId} from "./utils/NamingUtils"; +import {Jitsi} from "./widgets/Jitsi"; +import {WidgetType} from "./widgets/WidgetType"; +import {SettingLevel} from "./settings/SettingLevel"; +import { ActionPayload } from "./dispatcher/payloads"; +import {base32} from "rfc4648"; + +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; +import WidgetStore from "./stores/WidgetStore"; +import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; + +// until we ts-ify the js-sdk voip code +type Call = any; + +export default class CallHandler { + private calls = new Map(); + private audioPromises = new Map>(); + + static sharedInstance() { + if (!window.mxCallHandler) { + window.mxCallHandler = new CallHandler() + } + + return window.mxCallHandler; + } + + constructor() { + dis.register(this.onAction); + // add empty handlers for media actions, otherwise the media keys + // end up causing the audio elements with our ring/ringback etc + // audio clips in to play. + if (navigator.mediaSession) { + navigator.mediaSession.setActionHandler('play', function() {}); + navigator.mediaSession.setActionHandler('pause', function() {}); + navigator.mediaSession.setActionHandler('seekbackward', function() {}); + navigator.mediaSession.setActionHandler('seekforward', function() {}); + navigator.mediaSession.setActionHandler('previoustrack', function() {}); + navigator.mediaSession.setActionHandler('nexttrack', function() {}); + } + } + + getCallForRoom(roomId: string): Call { + return this.calls.get(roomId) || null; + } + + getAnyActiveCall() { + for (const call of this.calls.values()) { + if (call.state !== "ended") { + return call; + } + } + return null; + } + + play(audioId: string) { + // TODO: Attach an invisible element for this instead + // which listens? + const audio = document.getElementById(audioId) as HTMLMediaElement; + if (audio) { + const playAudio = async () => { + try { + // This still causes the chrome debugger to break on promise rejection if + // the promise is rejected, even though we're catching the exception. + await audio.play(); + } catch (e) { + // This is usually because the user hasn't interacted with the document, + // or chrome doesn't think so and is denying the request. Not sure what + // we can really do here... + // https://github.com/vector-im/element-web/issues/7657 + console.log("Unable to play audio clip", e); + } + }; + if (this.audioPromises.has(audioId)) { + this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => { + audio.load(); + return playAudio(); + })); + } else { + this.audioPromises.set(audioId, playAudio()); + } + } + } + + pause(audioId: string) { + // TODO: Attach an invisible element for this instead + // which listens? + const audio = document.getElementById(audioId) as HTMLMediaElement; + if (audio) { + if (this.audioPromises.has(audioId)) { + this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => audio.pause())); + } else { + // pause doesn't return a promise, so just do it + audio.pause(); + } + } + } + + private setCallListeners(call: Call) { + call.on("error", (err) => { + console.error("Call error:", err); + if ( + MatrixClientPeg.get().getTurnServers().length === 0 && + SettingsStore.getValue("fallbackICEServerAllowed") === null + ) { + this.showICEFallbackPrompt(); + return; + } + + Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { + title: _t('Call Failed'), + description: err.message, + }); + }); + call.on("hangup", () => { + this.removeCallForRoom(call.roomId); + }); + // map web rtc states to dummy UI state + // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing + call.on("state", (newState, oldState) => { + if (newState === "ringing") { + this.setCallState(call, call.roomId, "ringing"); + this.pause("ringbackAudio"); + } else if (newState === "invite_sent") { + this.setCallState(call, call.roomId, "ringback"); + this.play("ringbackAudio"); + } else if (newState === "ended" && oldState === "connected") { + this.removeCallForRoom(call.roomId); + this.pause("ringbackAudio"); + this.play("callendAudio"); + } else if (newState === "ended" && oldState === "invite_sent" && + (call.hangupParty === "remote" || + (call.hangupParty === "local" && call.hangupReason === "invite_timeout") + )) { + this.setCallState(call, call.roomId, "busy"); + this.pause("ringbackAudio"); + this.play("busyAudio"); + Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, { + title: _t('Call Timeout'), + description: _t('The remote side failed to pick up') + '.', + }); + } else if (oldState === "invite_sent") { + this.setCallState(call, call.roomId, "stop_ringback"); + this.pause("ringbackAudio"); + } else if (oldState === "ringing") { + this.setCallState(call, call.roomId, "stop_ringing"); + this.pause("ringbackAudio"); + } else if (newState === "connected") { + this.setCallState(call, call.roomId, "connected"); + this.pause("ringbackAudio"); + } + }); + } + + private setCallState(call: Call, roomId: string, status: string) { + console.log( + `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`, + ); + if (call) { + this.calls.set(roomId, call); + } else { + this.calls.delete(roomId); + } + + if (status === "ringing") { + this.play("ringAudio"); + } else if (call && call.call_state === "ringing") { + this.pause("ringAudio"); + } + + if (call) { + call.call_state = status; + } + dis.dispatch({ + action: 'call_state', + room_id: roomId, + state: status, + }); + } + + private removeCallForRoom(roomId: string) { + this.setCallState(null, roomId, null); + } + + private showICEFallbackPrompt() { + const cli = MatrixClientPeg.get(); + const code = sub => {sub}; + Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { + title: _t("Call failed due to misconfigured server"), + description:
+

{_t( + "Please ask the administrator of your homeserver " + + "(%(homeserverDomain)s) to configure a TURN server in " + + "order for calls to work reliably.", + { homeserverDomain: cli.getDomain() }, { code }, + )}

+

{_t( + "Alternatively, you can try to use the public server at " + + "turn.matrix.org, but this will not be as reliable, and " + + "it will share your IP address with that server. You can also manage " + + "this in Settings.", + null, { code }, + )}

+
, + button: _t('Try using turn.matrix.org'), + cancelButton: _t('OK'), + onFinished: (allow) => { + SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); + cli.setFallbackICEServerAllowed(allow); + }, + }, null, true); + } + + private onAction = (payload: ActionPayload) => { + const placeCall = (newCall) => { + this.setCallListeners(newCall); + if (payload.type === 'voice') { + newCall.placeVoiceCall(); + } else if (payload.type === 'video') { + newCall.placeVideoCall( + payload.remote_element, + payload.local_element, + ); + } else if (payload.type === 'screensharing') { + const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); + if (screenCapErrorString) { + this.removeCallForRoom(newCall.roomId); + console.log("Can't capture screen: " + screenCapErrorString); + Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { + title: _t('Unable to capture screen'), + description: screenCapErrorString, + }); + return; + } + newCall.placeScreenSharingCall( + payload.remote_element, + payload.local_element, + ); + } else { + console.error("Unknown conf call type: %s", payload.type); + } + } + + switch (payload.action) { + case 'place_call': + { + if (this.getAnyActiveCall()) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Existing Call'), + description: _t('You are already in a call.'), + }); + return; // don't allow >1 call to be placed. + } + + // if the runtime env doesn't do VoIP, whine. + if (!MatrixClientPeg.get().supportsVoip()) { + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), + }); + return; + } + + const room = MatrixClientPeg.get().getRoom(payload.room_id); + if (!room) { + console.error("Room %s does not exist.", payload.room_id); + return; + } + + const members = room.getJoinedMembers(); + if (members.length <= 1) { + Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { + description: _t('You cannot place a call with yourself.'), + }); + return; + } else if (members.length === 2) { + console.info("Place %s call in %s", payload.type, payload.room_id); + const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); + placeCall(call); + } else { // > 2 + dis.dispatch({ + action: "place_conference_call", + room_id: payload.room_id, + type: payload.type, + remote_element: payload.remote_element, + local_element: payload.local_element, + }); + } + } + break; + case 'place_conference_call': + console.info("Place conference call in %s", payload.room_id); + this.startCallApp(payload.room_id, payload.type); + break; + case 'end_conference': + console.info("Terminating conference call in %s", payload.room_id); + this.terminateCallApp(payload.room_id); + break; + case 'hangup_conference': + console.info("Leaving conference call in %s", payload.room_id); + this.hangupCallApp(payload.room_id); + break; + case 'incoming_call': + { + if (this.getAnyActiveCall()) { + // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. + // we avoid rejecting with "busy" in case the user wants to answer it on a different device. + // in future we could signal a "local busy" as a warning to the caller. + // see https://github.com/vector-im/vector-web/issues/1964 + return; + } + + // if the runtime env doesn't do VoIP, stop here. + if (!MatrixClientPeg.get().supportsVoip()) { + return; + } + + const call = payload.call; + this.setCallListeners(call); + this.setCallState(call, call.roomId, "ringing"); + } + break; + case 'hangup': + if (!this.calls.get(payload.room_id)) { + return; // no call to hangup + } + this.calls.get(payload.room_id).hangup(); + this.removeCallForRoom(payload.room_id); + break; + case 'answer': + if (!this.calls.get(payload.room_id)) { + return; // no call to answer + } + this.calls.get(payload.room_id).answer(); + this.setCallState(this.calls.get(payload.room_id), payload.room_id, "connected"); + dis.dispatch({ + action: "view_room", + room_id: payload.room_id, + }); + break; + } + } + + private async startCallApp(roomId: string, type: string) { + dis.dispatch({ + action: 'appsDrawer', + show: true, + }); + + // prevent double clicking the call button + const room = MatrixClientPeg.get().getRoom(roomId); + const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); + const hasJitsi = currentJitsiWidgets.length > 0 + || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); + if (hasJitsi) { + Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { + title: _t('Call in Progress'), + description: _t('A call is currently being placed!'), + }); + return; + } + + const jitsiDomain = Jitsi.getInstance().preferredDomain; + const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); + let confId; + if (jitsiAuth === 'openidtoken-jwt') { + // Create conference ID from room ID + // For compatibility with Jitsi, use base32 without padding. + // More details here: + // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + confId = base32.stringify(Buffer.from(roomId), { pad: false }); + } else { + // Create a random human readable conference ID + confId = `JitsiConference${generateHumanReadableId()}`; + } + + let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); + + // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets + const parsedUrl = new URL(widgetUrl); + parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead + parsedUrl.searchParams.set('confId', confId); + widgetUrl = parsedUrl.toString(); + + const widgetData = { + conferenceId: confId, + isAudioOnly: type === 'voice', + domain: jitsiDomain, + auth: jitsiAuth, + }; + + const widgetId = ( + 'jitsi_' + + MatrixClientPeg.get().credentials.userId + + '_' + + Date.now() + ); + + WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => { + console.log('Jitsi widget added'); + }).catch((e) => { + if (e.errcode === 'M_FORBIDDEN') { + Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { + title: _t('Permission Required'), + description: _t("You do not have permission to start a conference call in this room"), + }); + } + console.error(e); + }); + } + + private terminateCallApp(roomId: string) { + Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, { + hasCancelButton: true, + title: _t("End conference"), + description: _t("This will end the conference for everyone. Continue?"), + button: _t("End conference"), + onFinished: (proceed) => { + if (!proceed) return; + + // We'll just obliterate them all. There should only ever be one, but might as well + // be safe. + const roomInfo = WidgetStore.instance.getRoom(roomId); + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + // setting invalid content removes it + WidgetUtils.setRoomWidget(roomId, w.id); + }); + }, + }); + } + + private hangupCallApp(roomId: string) { + const roomInfo = WidgetStore.instance.getRoom(roomId); + if (!roomInfo) return; // "should never happen" clauses go here + + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id); + if (!messaging) return; // more "should never happen" words + + messaging.transport.send(ElementWidgetActions.HangupCall, {}); + }); + } +} diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js deleted file mode 100644 index d5d7c08d50..0000000000 --- a/src/FromWidgetPostMessageApi.js +++ /dev/null @@ -1,275 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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. -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 URL from 'url'; -import dis from './dispatcher/dispatcher'; -import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; -import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import RoomViewStore from "./stores/RoomViewStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import SettingsStore from "./settings/SettingsStore"; -import {Capability} from "./widgets/WidgetApi"; -import {objectClone} from "./utils/objects"; - -const WIDGET_API_VERSION = '0.0.2'; // Current API version -const SUPPORTED_WIDGET_API_VERSIONS = [ - '0.0.1', - '0.0.2', -]; -const INBOUND_API_NAME = 'fromWidget'; - -// Listen for and handle incoming requests using the 'fromWidget' postMessage -// API and initiate responses -export default class FromWidgetPostMessageApi { - constructor() { - this.widgetMessagingEndpoints = []; - this.widgetListeners = {}; // {action: func[]} - - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - /** - * Adds a listener for a given action - * @param {string} action The action to listen for. - * @param {Function} callbackFn A callback function to be called when the action is - * encountered. Called with two parameters: the interesting request information and - * the raw event received from the postMessage API. The raw event is meant to be used - * for sendResponse and similar functions. - */ - addListener(action, callbackFn) { - if (!this.widgetListeners[action]) this.widgetListeners[action] = []; - this.widgetListeners[action].push(callbackFn); - } - - /** - * Removes a listener for a given action. - * @param {string} action The action that was subscribed to. - * @param {Function} callbackFn The original callback function that was used to subscribe - * to updates. - */ - removeListener(action, callbackFn) { - if (!this.widgetListeners[action]) return; - - const idx = this.widgetListeners[action].indexOf(callbackFn); - if (idx !== -1) this.widgetListeners[action].splice(idx, 1); - } - - /** - * Register a widget endpoint for trusted postMessage communication - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - */ - addEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl); - return; - } - - const origin = u.protocol + '//' + u.host; - const endpoint = new WidgetMessagingEndpoint(widgetId, origin); - if (this.widgetMessagingEndpoints.some(function(ep) { - return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); - })) { - // Message endpoint already registered - console.warn('Add FromWidgetPostMessageApi - Endpoint already registered'); - return; - } else { - console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint); - this.widgetMessagingEndpoints.push(endpoint); - } - } - - /** - * De-register a widget endpoint from trusted communication sources - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - * @return {boolean} True if endpoint was successfully removed - */ - removeEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Remove widget messaging endpoint - Invalid origin'); - return; - } - - const origin = u.protocol + '//' + u.host; - if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) { - const length = this.widgetMessagingEndpoints.length; - this.widgetMessagingEndpoints = this.widgetMessagingEndpoints - .filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin); - return (length > this.widgetMessagingEndpoints.length); - } - return false; - } - - /** - * Handle widget postMessage events - * Messages are only handled where a valid, registered messaging endpoints - * @param {Event} event Event to handle - * @return {undefined} - */ - onPostMessage(event) { - if (!event.origin) { // Handle chrome - event.origin = event.originalEvent.origin; - } - - // Event origin is empty string if undefined - if ( - event.origin.length === 0 || - !this.trustedEndpoint(event.origin) || - event.data.api !== INBOUND_API_NAME || - !event.data.widgetId - ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise - } - - // Call any listeners we have registered - if (this.widgetListeners[event.data.action]) { - for (const fn of this.widgetListeners[event.data.action]) { - fn(event.data, event); - } - } - - // Although the requestId is required, we don't use it. We'll be nice and process the message - // if the property is missing, but with a warning for widget developers. - if (!event.data.requestId) { - console.warn("fromWidget action '" + event.data.action + "' does not have a requestId"); - } - - const action = event.data.action; - const widgetId = event.data.widgetId; - if (action === 'content_loaded') { - console.log('Widget reported content loaded for', widgetId); - dis.dispatch({ - action: 'widget_content_loaded', - widgetId: widgetId, - }); - this.sendResponse(event, {success: true}); - } else if (action === 'supported_api_versions') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - supported_versions: SUPPORTED_WIDGET_API_VERSIONS, - }); - } else if (action === 'api_version') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - version: WIDGET_API_VERSION, - }); - } else if (action === 'm.sticker') { - // console.warn('Got sticker message from widget', widgetId); - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId}); - } else if (action === 'integration_manager_open') { - // Close the stickerpicker - dis.dispatch({action: 'stickerpicker_close'}); - // Open the integration manager - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - const integType = (data && data.integType) ? data.integType : null; - const integId = (data && data.integId) ? data.integId : null; - - // TODO: Open the right integration manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } - } else if (action === 'set_always_on_screen') { - // This is a new message: there is no reason to support the deprecated widgetData here - const data = event.data.data; - const val = data.value; - - if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { - ActiveWidgetStore.setWidgetPersistence(widgetId, val); - } - } else if (action === 'get_openid') { - // Handled by caller - } else { - console.warn('Widget postMessage event unhandled'); - this.sendError(event, {message: 'The postMessage was unhandled'}); - } - } - - /** - * Check if message origin is registered as trusted - * @param {string} origin PostMessage origin to check - * @return {boolean} True if trusted - */ - trustedEndpoint(origin) { - if (!origin) { - return false; - } - - return this.widgetMessagingEndpoints.some((endpoint) => { - // TODO / FIXME -- Should this also check the widgetId? - return endpoint.endpointUrl === origin; - }); - } - - /** - * Send a postmessage response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {Object} res Response data - */ - sendResponse(event, res) { - const data = objectClone(event.data); - data.response = res; - event.source.postMessage(data, event.origin); - } - - /** - * Send an error response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {string} msg Error message - * @param {Error} nestedError Nested error event (optional) - */ - sendError(event, msg, nestedError) { - console.error('Action:' + event.data.action + ' failed with message: ' + msg); - const data = objectClone(event.data); - data.response = { - error: { - message: msg, - }, - }; - if (nestedError) { - data.response.error._error = nestedError; - } - event.source.postMessage(data, event.origin); - } -} diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index ca718cd9aa..6bae0b25b6 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -19,6 +19,7 @@ limitations under the License. import React from 'react'; import sanitizeHtml from 'sanitize-html'; +import { IExtendedSanitizeOptions } from './@types/sanitize-html'; import * as linkify from 'linkifyjs'; import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; @@ -55,7 +56,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; /* * Return true if the given string contains emoji @@ -154,7 +155,7 @@ export function isUrlPermitted(inputUrl: string) { } } -const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix +const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to matrix // add blank targets to all hyperlinks except vector URLs 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) { if (attribs.href) { @@ -227,7 +228,7 @@ const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to mat }, }; -const sanitizeHtmlParams: sanitizeHtml.IOptions = { +const sanitizeHtmlParams: IExtendedSanitizeOptions = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown @@ -249,13 +250,14 @@ const sanitizeHtmlParams: sanitizeHtml.IOptions = { selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit allowedSchemes: PERMITTED_URL_SCHEMES, - allowProtocolRelative: false, transformTags, + // 50 levels deep "should be enough for anyone" + nestingLimit: 50, }; // this is the same as the above except with less rewriting -const composerSanitizeHtmlParams: sanitizeHtml.IOptions = { +const composerSanitizeHtmlParams: IExtendedSanitizeOptions = { ...sanitizeHtmlParams, transformTags: { 'code': transformTags['code'], diff --git a/src/Lifecycle.js b/src/Lifecycle.ts similarity index 81% rename from src/Lifecycle.js rename to src/Lifecycle.ts index 3a48de5eef..f2cd1bce9e 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.ts @@ -17,9 +17,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising import Matrix from 'matrix-js-sdk'; +import { InvalidStoreError } from "matrix-js-sdk/src/errors"; +import { MatrixClient } from "matrix-js-sdk/src/client"; -import {MatrixClientPeg} from './MatrixClientPeg'; +import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; import Analytics from './Analytics'; @@ -47,44 +50,46 @@ import ThreepidInviteStore from "./stores/ThreepidInviteStore"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; +interface ILoadSessionOpts { + enableGuest?: boolean; + guestHsUrl?: string; + guestIsUrl?: string; + ignoreGuest?: boolean; + defaultDeviceDisplayName?: string; + fragmentQueryParams?: Record; +} + /** * Called at startup, to attempt to build a logged-in Matrix session. It tries * a number of things: * - * * 1. if we have a guest access token in the fragment query params, it uses * that. - * * 2. if an access token is stored in local storage (from a previous session), * it uses that. - * * 3. it attempts to auto-register as a guest user. * * If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in * turn will raise on_logged_in and will_start_client events. * - * @param {object} opts - * - * @param {object} opts.fragmentQueryParams: string->string map of the + * @param {object} [opts] + * @param {object} [opts.fragmentQueryParams]: string->string map of the * query-parameters extracted from the #-fragment of the starting URI. - * - * @param {boolean} opts.enableGuest: set to true to enable guest access tokens - * and auto-guest registrations. - * - * @params {string} opts.guestHsUrl: homeserver URL. Only used if enableGuest is - * true; defines the HS to register against. - * - * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is - * true; defines the IS to use. - * - * @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore - * it and don't load it. - * + * @param {boolean} [opts.enableGuest]: set to true to enable guest access + * tokens and auto-guest registrations. + * @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest + * is true; defines the HS to register against. + * @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest + * is true; defines the IS to use. + * @param {bool} [opts.ignoreGuest]: If the stored session is a guest account, + * ignore it and don't load it. + * @param {string} [opts.defaultDeviceDisplayName]: Default display name to use + * when registering as a guest. * @returns {Promise} a promise which resolves when the above process completes. * Resolves to `true` if we ended up starting a session, or `false` if we * failed. */ -export async function loadSession(opts) { +export async function loadSession(opts: ILoadSessionOpts = {}): Promise { try { let enableGuest = opts.enableGuest || false; const guestHsUrl = opts.guestHsUrl; @@ -97,12 +102,13 @@ export async function loadSession(opts) { enableGuest = false; } - if (enableGuest && + if ( + enableGuest && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token - ) { + ) { console.log("Using guest access credentials"); - return _doSetLoggedIn({ + return doSetLoggedIn({ userId: fragmentQueryParams.guest_user_id, accessToken: fragmentQueryParams.guest_access_token, homeserverUrl: guestHsUrl, @@ -110,7 +116,7 @@ export async function loadSession(opts) { guest: true, }, true).then(() => true); } - const success = await _restoreFromLocalStorage({ + const success = await restoreFromLocalStorage({ ignoreGuest: Boolean(opts.ignoreGuest), }); if (success) { @@ -118,7 +124,7 @@ export async function loadSession(opts) { } if (enableGuest) { - return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); + return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); } // fall back to welcome screen @@ -129,7 +135,7 @@ export async function loadSession(opts) { // need to show the general failure dialog. Instead, just go back to welcome. return false; } - return _handleLoadSessionFailure(e); + return handleLoadSessionFailure(e); } } @@ -139,7 +145,7 @@ export async function loadSession(opts) { * is associated with them. The session is not loaded. * @returns {String} The persisted session's owner, if an owner exists. Null otherwise. */ -export function getStoredSessionOwner() { +export function getStoredSessionOwner(): string { const {hsUrl, userId, accessToken} = getLocalStorageSessionVars(); return hsUrl && userId && accessToken ? userId : null; } @@ -148,7 +154,7 @@ export function getStoredSessionOwner() { * @returns {bool} True if the stored session is for a guest user or false if it is * for a real user. If there is no stored session, return null. */ -export function getStoredSessionIsGuest() { +export function getStoredSessionIsGuest(): boolean { const sessVars = getLocalStorageSessionVars(); return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null; } @@ -163,7 +169,10 @@ export function getStoredSessionIsGuest() { * @returns {Promise} promise which resolves to true if we completed the token * login, else false */ -export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { +export function attemptTokenLogin( + queryParams: Record, + defaultDeviceDisplayName?: string, +): Promise { if (!queryParams.loginToken) { return Promise.resolve(false); } @@ -184,8 +193,10 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { }, ).then(function(creds) { console.log("Logged in with token"); - return _clearStorage().then(() => { - _persistCredentialsToLocalStorage(creds); + return clearStorage().then(() => { + persistCredentialsToLocalStorage(creds); + // remember that we just logged in + sessionStorage.setItem("mx_fresh_login", String(true)); return true; }); }).catch((err) => { @@ -195,8 +206,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { }); } -export function handleInvalidStoreError(e) { - if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) { +export function handleInvalidStoreError(e: InvalidStoreError): Promise { + if (e.reason === InvalidStoreError.TOGGLED_LAZY_LOADING) { return Promise.resolve().then(() => { const lazyLoadEnabled = e.value; if (lazyLoadEnabled) { @@ -229,7 +240,11 @@ export function handleInvalidStoreError(e) { } } -function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { +function registerAsGuest( + hsUrl: string, + isUrl: string, + defaultDeviceDisplayName: string, +): Promise { console.log(`Doing guest login on ${hsUrl}`); // create a temporary MatrixClient to do the login @@ -243,7 +258,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { }, }).then((creds) => { console.log(`Registered as guest: ${creds.user_id}`); - return _doSetLoggedIn({ + return doSetLoggedIn({ userId: creds.user_id, deviceId: creds.device_id, accessToken: creds.access_token, @@ -257,12 +272,21 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { }); } +export interface ILocalStorageSession { + hsUrl: string; + isUrl: string; + accessToken: string; + userId: string; + deviceId: string; + isGuest: boolean; +} + /** * Retrieves information about the stored session in localstorage. The session * may not be valid, as it is not tested for consistency here. * @returns {Object} Information about the session - see implementation for variables. */ -export function getLocalStorageSessionVars() { +export function getLocalStorageSessionVars(): ILocalStorageSession { const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); const accessToken = localStorage.getItem("mx_access_token"); @@ -290,8 +314,8 @@ export function getLocalStorageSessionVars() { // The plan is to gradually move the localStorage access done here into // SessionStore to avoid bugs where the view becomes out-of-sync with // localStorage (e.g. isGuest etc.) -async function _restoreFromLocalStorage(opts) { - const ignoreGuest = opts.ignoreGuest; +async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise { + const ignoreGuest = opts?.ignoreGuest; if (!localStorage) { return false; @@ -312,8 +336,11 @@ async function _restoreFromLocalStorage(opts) { console.log("No pickle key available"); } + const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true"; + sessionStorage.removeItem("mx_fresh_login"); + console.log(`Restoring session for ${userId}`); - await _doSetLoggedIn({ + await doSetLoggedIn({ userId: userId, deviceId: deviceId, accessToken: accessToken, @@ -321,6 +348,7 @@ async function _restoreFromLocalStorage(opts) { identityServerUrl: isUrl, guest: isGuest, pickleKey: pickleKey, + freshLogin: freshLogin, }, false); return true; } else { @@ -329,7 +357,7 @@ async function _restoreFromLocalStorage(opts) { } } -async function _handleLoadSessionFailure(e) { +async function handleLoadSessionFailure(e: Error): Promise { console.error("Unable to load session", e); const SessionRestoreErrorDialog = @@ -342,7 +370,7 @@ async function _handleLoadSessionFailure(e) { const [success] = await modal.finished; if (success) { // user clicked continue. - await _clearStorage(); + await clearStorage(); return false; } @@ -363,11 +391,12 @@ async function _handleLoadSessionFailure(e) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -export async function setLoggedIn(credentials) { +export async function setLoggedIn(credentials: IMatrixClientCreds): Promise { + credentials.freshLogin = true; stopMatrixClient(); const pickleKey = credentials.userId && credentials.deviceId - ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) - : null; + ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) + : null; if (pickleKey) { console.log("Created pickle key"); @@ -375,7 +404,7 @@ export async function setLoggedIn(credentials) { console.log("Pickle key not created"); } - return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true); + return doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true); } /** @@ -393,7 +422,7 @@ export async function setLoggedIn(credentials) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -export function hydrateSession(credentials) { +export function hydrateSession(credentials: IMatrixClientCreds): Promise { const oldUserId = MatrixClientPeg.get().getUserId(); const oldDeviceId = MatrixClientPeg.get().getDeviceId(); @@ -406,7 +435,7 @@ export function hydrateSession(credentials) { console.warn("Clearing all data: Old session belongs to a different user/session"); } - return _doSetLoggedIn(credentials, overwrite); + return doSetLoggedIn(credentials, overwrite); } /** @@ -418,7 +447,10 @@ export function hydrateSession(credentials) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -async function _doSetLoggedIn(credentials, clearStorage) { +async function doSetLoggedIn( + credentials: IMatrixClientCreds, + clearStorageEnabled: boolean, +): Promise { credentials.guest = Boolean(credentials.guest); const softLogout = isSoftLogout(); @@ -429,6 +461,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { " guest: " + credentials.guest + " hs: " + credentials.homeserverUrl + " softLogout: " + softLogout, + " freshLogin: " + credentials.freshLogin, ); // This is dispatched to indicate that the user is still in the process of logging in @@ -440,8 +473,8 @@ async function _doSetLoggedIn(credentials, clearStorage) { // (dis.dispatch uses `setTimeout`, which does not guarantee ordering.) dis.dispatch({action: 'on_logging_in'}, true); - if (clearStorage) { - await _clearStorage(); + if (clearStorageEnabled) { + await clearStorage(); } const results = await StorageManager.checkConsistency(); @@ -449,9 +482,9 @@ async function _doSetLoggedIn(credentials, clearStorage) { // crypto store, we'll be generally confused when handling encrypted data. // Show a modal recommending a full reset of storage. if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { - const signOut = await _showStorageEvictedDialog(); + const signOut = await showStorageEvictedDialog(); if (signOut) { - await _clearStorage(); + await clearStorage(); // This error feels a bit clunky, but we want to make sure we don't go any // further and instead head back to sign in. throw new AbortLoginAndRebuildStorage( @@ -462,19 +495,26 @@ async function _doSetLoggedIn(credentials, clearStorage) { Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); + MatrixClientPeg.replaceUsingCreds(credentials); + const client = MatrixClientPeg.get(); + + if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { + // If we just logged in, try to rehydrate a device instead of using a + // new device. If it succeeds, we'll get a new device ID, so make sure + // we persist that ID to localStorage + const newDeviceId = await client.rehydrateDevice(); + if (newDeviceId) { + credentials.deviceId = newDeviceId; + } + + delete credentials.freshLogin; + } + if (localStorage) { try { - _persistCredentialsToLocalStorage(credentials); - - // The user registered as a PWLU (PassWord-Less User), the generated password - // is cached here such that the user can change it at a later time. - if (credentials.password) { - // Update SessionStore - dis.dispatch({ - action: 'cached_password', - cachedPassword: credentials.password, - }); - } + persistCredentialsToLocalStorage(credentials); + // make sure we don't think that it's a fresh login any more + sessionStorage.removeItem("mx_fresh_login"); } catch (e) { console.warn("Error using local storage: can't persist session!", e); } @@ -482,15 +522,13 @@ async function _doSetLoggedIn(credentials, clearStorage) { console.warn("No local storage available: can't persist session!"); } - MatrixClientPeg.replaceUsingCreds(credentials); - dis.dispatch({ action: 'on_logged_in' }); await startMatrixClient(/*startSyncing=*/!softLogout); - return MatrixClientPeg.get(); + return client; } -function _showStorageEvictedDialog() { +function showStorageEvictedDialog(): Promise { const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { @@ -503,7 +541,7 @@ function _showStorageEvictedDialog() { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error { } -function _persistCredentialsToLocalStorage(credentials) { +function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void { localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); if (credentials.identityServerUrl) { localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl); @@ -513,7 +551,7 @@ function _persistCredentialsToLocalStorage(credentials) { localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); if (credentials.pickleKey) { - localStorage.setItem("mx_has_pickle_key", true); + localStorage.setItem("mx_has_pickle_key", String(true)); } else { if (localStorage.getItem("mx_has_pickle_key")) { console.error("Expected a pickle key, but none provided. Encryption may not work."); @@ -537,7 +575,7 @@ let _isLoggingOut = false; /** * Logs the current session out and transitions to the logged-out state */ -export function logout() { +export function logout(): void { if (!MatrixClientPeg.get()) return; if (MatrixClientPeg.get().isGuest()) { @@ -566,7 +604,7 @@ export function logout() { ); } -export function softLogout() { +export function softLogout(): void { if (!MatrixClientPeg.get()) return; // Track that we've detected and trapped a soft logout. This helps prevent other @@ -587,11 +625,11 @@ export function softLogout() { // DO NOT CALL LOGOUT. A soft logout preserves data, logout does not. } -export function isSoftLogout() { +export function isSoftLogout(): boolean { return localStorage.getItem("mx_soft_logout") === "true"; } -export function isLoggingOut() { +export function isLoggingOut(): boolean { return _isLoggingOut; } @@ -601,7 +639,7 @@ export function isLoggingOut() { * @param {boolean} startSyncing True (default) to actually start * syncing the client. */ -async function startMatrixClient(startSyncing=true) { +async function startMatrixClient(startSyncing = true): Promise { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -660,21 +698,21 @@ async function startMatrixClient(startSyncing=true) { * Stops a running client and all related services, and clears persistent * storage. Used after a session has been logged out. */ -export async function onLoggedOut() { +export async function onLoggedOut(): Promise { _isLoggingOut = false; // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); - await _clearStorage({deleteEverything: true}); + await clearStorage({deleteEverything: true}); } /** * @param {object} opts Options for how to clear storage. * @returns {Promise} promise which resolves once the stores have been cleared */ -async function _clearStorage(opts: {deleteEverything: boolean}) { +async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { Analytics.disable(); if (window.localStorage) { @@ -712,7 +750,7 @@ async function _clearStorage(opts: {deleteEverything: boolean}) { * @param {boolean} unsetClient True (default) to abandon the client * on MatrixClientPeg after stopping. */ -export function stopMatrixClient(unsetClient=true) { +export function stopMatrixClient(unsetClient = true): void { Notifier.stop(); UserActivity.sharedInstance().stop(); TypingStore.sharedInstance().reset(); diff --git a/src/Login.js b/src/Login.ts similarity index 56% rename from src/Login.js rename to src/Login.ts index 04805b4af9..38d78feab6 100644 --- a/src/Login.js +++ b/src/Login.ts @@ -18,35 +18,72 @@ See the License for the specific language governing permissions and limitations under the License. */ +// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising import Matrix from "matrix-js-sdk"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { IMatrixClientCreds } from "./MatrixClientPeg"; + +interface ILoginOptions { + defaultDeviceDisplayName?: string; +} + +// TODO: Move this to JS SDK +interface ILoginFlow { + type: string; +} + +// TODO: Move this to JS SDK +/* eslint-disable camelcase */ +interface ILoginParams { + identifier?: string; + password?: string; + token?: string; + device_id?: string; + initial_device_display_name?: string; +} +/* eslint-enable camelcase */ export default class Login { - constructor(hsUrl, isUrl, fallbackHsUrl, opts) { - this._hsUrl = hsUrl; - this._isUrl = isUrl; - this._fallbackHsUrl = fallbackHsUrl; - this._currentFlowIndex = 0; - this._flows = []; - this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - this._tempClient = null; // memoize + private hsUrl: string; + private isUrl: string; + private fallbackHsUrl: string; + private currentFlowIndex: number; + // TODO: Flows need a type in JS SDK + private flows: Array; + private defaultDeviceDisplayName: string; + private tempClient: MatrixClient; + + constructor( + hsUrl: string, + isUrl: string, + fallbackHsUrl?: string, + opts?: ILoginOptions, + ) { + this.hsUrl = hsUrl; + this.isUrl = isUrl; + this.fallbackHsUrl = fallbackHsUrl; + this.currentFlowIndex = 0; + this.flows = []; + this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + this.tempClient = null; // memoize } - getHomeserverUrl() { - return this._hsUrl; + public getHomeserverUrl(): string { + return this.hsUrl; } - getIdentityServerUrl() { - return this._isUrl; + public getIdentityServerUrl(): string { + return this.isUrl; } - setHomeserverUrl(hsUrl) { - this._tempClient = null; // clear memoization - this._hsUrl = hsUrl; + public setHomeserverUrl(hsUrl: string): void { + this.tempClient = null; // clear memoization + this.hsUrl = hsUrl; } - setIdentityServerUrl(isUrl) { - this._tempClient = null; // clear memoization - this._isUrl = isUrl; + public setIdentityServerUrl(isUrl: string): void { + this.tempClient = null; // clear memoization + this.isUrl = isUrl; } /** @@ -54,40 +91,41 @@ export default class Login { * requests. * @returns {MatrixClient} */ - createTemporaryClient() { - if (this._tempClient) return this._tempClient; // use memoization - return this._tempClient = Matrix.createClient({ - baseUrl: this._hsUrl, - idBaseUrl: this._isUrl, + public createTemporaryClient(): MatrixClient { + if (this.tempClient) return this.tempClient; // use memoization + return this.tempClient = Matrix.createClient({ + baseUrl: this.hsUrl, + idBaseUrl: this.isUrl, }); } - getFlows() { - const self = this; + public async getFlows(): Promise> { const client = this.createTemporaryClient(); - return client.loginFlows().then(function(result) { - self._flows = result.flows; - self._currentFlowIndex = 0; - // technically the UI should display options for all flows for the - // user to then choose one, so return all the flows here. - return self._flows; - }); + const { flows } = await client.loginFlows(); + this.flows = flows; + this.currentFlowIndex = 0; + // technically the UI should display options for all flows for the + // user to then choose one, so return all the flows here. + return this.flows; } - chooseFlow(flowIndex) { - this._currentFlowIndex = flowIndex; + public chooseFlow(flowIndex): void { + this.currentFlowIndex = flowIndex; } - getCurrentFlowStep() { + public getCurrentFlowStep(): string { // technically the flow can have multiple steps, but no one does this // for login so we can ignore it. - const flowStep = this._flows[this._currentFlowIndex]; + const flowStep = this.flows[this.currentFlowIndex]; return flowStep ? flowStep.type : null; } - loginViaPassword(username, phoneCountry, phoneNumber, pass) { - const self = this; - + public loginViaPassword( + username: string, + phoneCountry: string, + phoneNumber: string, + password: string, + ): Promise { const isEmail = username.indexOf("@") > 0; let identifier; @@ -113,14 +151,14 @@ export default class Login { } const loginParams = { - password: pass, - identifier: identifier, - initial_device_display_name: this._defaultDeviceDisplayName, + password, + identifier, + initial_device_display_name: this.defaultDeviceDisplayName, }; const tryFallbackHs = (originalError) => { return sendLoginRequest( - self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams, + this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams, ).catch((fallbackError) => { console.log("fallback HS login failed", fallbackError); // throw the original error @@ -130,11 +168,11 @@ export default class Login { let originalLoginError = null; return sendLoginRequest( - self._hsUrl, self._isUrl, 'm.login.password', loginParams, + this.hsUrl, this.isUrl, 'm.login.password', loginParams, ).catch((error) => { originalLoginError = error; if (error.httpStatus === 403) { - if (self._fallbackHsUrl) { + if (this.fallbackHsUrl) { return tryFallbackHs(originalLoginError); } } @@ -154,11 +192,16 @@ export default class Login { * @param {string} hsUrl the base url of the Homeserver used to log in. * @param {string} isUrl the base url of the default identity server * @param {string} loginType the type of login to do - * @param {object} loginParams the parameters for the login + * @param {ILoginParams} loginParams the parameters for the login * * @returns {MatrixClientCreds} */ -export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { +export async function sendLoginRequest( + hsUrl: string, + isUrl: string, + loginType: string, + loginParams: ILoginParams, +): Promise { const client = Matrix.createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 9589130e7f..4651a0afe3 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -31,17 +31,18 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; -import { crossSigningCallbacks } from './SecurityManager'; +import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; export interface IMatrixClientCreds { homeserverUrl: string; identityServerUrl: string; userId: string; - deviceId: string; + deviceId?: string; accessToken: string; - guest: boolean; + guest?: boolean; pickleKey?: string; + freshLogin?: boolean; } // TODO: Move this to the js-sdk @@ -192,6 +193,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { this.matrixClient.setCryptoTrustCrossSignedDevices( !SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'), ); + await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient); StorageManager.setCryptoInitialised(true); } } catch (e) { diff --git a/src/Registration.js b/src/Registration.js index 9c0264c067..0df2ec3eb3 100644 --- a/src/Registration.js +++ b/src/Registration.js @@ -24,7 +24,6 @@ import dis from './dispatcher/dispatcher'; import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; -// import {MatrixClientPeg} from './MatrixClientPeg'; // Regex for what a "safe" or "Matrix-looking" localpart would be. // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514 @@ -44,70 +43,27 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/; */ export async function startAnyRegistrationFlow(options) { if (options === undefined) options = {}; - // look for an ILAG compatible flow. We define this as one - // which has only dummy or recaptcha flows. In practice it - // would support any stage InteractiveAuth supports, just not - // ones like email & msisdn which require the user to supply - // the relevant details in advance. We err on the side of - // caution though. - - // XXX: ILAG is disabled for now, - // see https://github.com/vector-im/element-web/issues/8222 - - // const flows = await _getRegistrationFlows(); - // const hasIlagFlow = flows.some((flow) => { - // return flow.stages.every((stage) => { - // return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage); - // }); - // }); - - // if (hasIlagFlow) { - // dis.dispatch({ - // action: 'view_set_mxid', - // go_home_on_cancel: options.go_home_on_cancel, - // }); - //} else { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { - hasCancelButton: true, - quitOnly: true, - title: _t("Sign In or Create Account"), - description: _t("Use your account or create a new one to continue."), - button: _t("Create Account"), - extraButtons: [ - , - ], - onFinished: (proceed) => { - if (proceed) { - dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); - } else if (options.go_home_on_cancel) { - dis.dispatch({action: 'view_home_page'}); - } else if (options.go_welcome_on_cancel) { - dis.dispatch({action: 'view_welcome_page'}); - } - }, - }); - //} + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { + hasCancelButton: true, + quitOnly: true, + title: _t("Sign In or Create Account"), + description: _t("Use your account or create a new one to continue."), + button: _t("Create Account"), + extraButtons: [ + , + ], + onFinished: (proceed) => { + if (proceed) { + dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); + } else if (options.go_home_on_cancel) { + dis.dispatch({action: 'view_home_page'}); + } else if (options.go_welcome_on_cancel) { + dis.dispatch({action: 'view_welcome_page'}); + } + }, + }); } - -// async function _getRegistrationFlows() { -// try { -// await MatrixClientPeg.get().register( -// null, -// null, -// undefined, -// {}, -// {}, -// ); -// console.log("Register request succeeded when it should have returned 401!"); -// } catch (e) { -// if (e.httpStatus === 401) { -// return e.data.flows; -// } -// throw e; -// } -// throw new Error("Register request succeeded when it should have returned 401!"); -// } diff --git a/src/Rooms.js b/src/Rooms.js index 218e970f35..3da2b9bc14 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -26,58 +26,6 @@ export function getDisplayAliasForRoom(room) { return room.getCanonicalAlias() || room.getAltAliases()[0]; } -/** - * If the room contains only two members including the logged-in user, - * return the other one. Otherwise, return null. - */ -export function getOnlyOtherMember(room, myUserId) { - if (room.currentState.getJoinedMemberCount() === 2) { - return room.getJoinedMembers().filter(function(m) { - return m.userId !== myUserId; - })[0]; - } - - return null; -} - -function _isConfCallRoom(room, myUserId, conferenceHandler) { - if (!conferenceHandler) return false; - - const myMembership = room.getMyMembership(); - if (myMembership != "join") { - return false; - } - - const otherMember = getOnlyOtherMember(room, myUserId); - if (!otherMember) { - return false; - } - - if (conferenceHandler.isConferenceUser(otherMember.userId)) { - return true; - } - - return false; -} - -// Cache whether a room is a conference call. Assumes that rooms will always -// either will or will not be a conference call room. -const isConfCallRoomCache = { - // $roomId: bool -}; - -export function isConfCallRoom(room, myUserId, conferenceHandler) { - if (isConfCallRoomCache[room.roomId] !== undefined) { - return isConfCallRoomCache[room.roomId]; - } - - const result = _isConfCallRoom(room, myUserId, conferenceHandler); - - isConfCallRoomCache[room.roomId] = result; - - return result; -} - export function looksLikeDirectMessageRoom(room, myUserId) { const myMembership = room.getMyMembership(); const me = room.getMember(myUserId); diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index b914aaaf6d..7d7caa2d24 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -33,6 +33,11 @@ export const DEFAULTS: ConfigOptions = { // Default conference domain preferredDomain: "jitsi.riot.im", }, + desktopBuilds: { + available: true, + logo: require("../res/img/element-desktop-logo.svg"), + url: "https://element.io/get-started", + }, }; export default class SdkConfig { diff --git a/src/SecurityManager.js b/src/SecurityManager.js index f6b9c993d0..3272c0f015 100644 --- a/src/SecurityManager.js +++ b/src/SecurityManager.js @@ -24,6 +24,7 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; import { isSecureBackupRequired } from './utils/WellKnownUtils'; import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; +import SettingsStore from "./settings/SettingsStore"; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times @@ -31,8 +32,13 @@ import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreK // single secret storage operation, as it will clear the cached keys once the // operation ends. let secretStorageKeys = {}; +let secretStorageKeyInfo = {}; let secretStorageBeingAccessed = false; +let nonInteractive = false; + +let dehydrationCache = {}; + function isCachingAllowed() { return secretStorageBeingAccessed; } @@ -66,6 +72,20 @@ async function confirmToDismiss() { return !sure; } +function makeInputToKey(keyInfo) { + return async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + keyInfo.passphrase.salt, + keyInfo.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; +} + async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const keyInfoEntries = Object.entries(keyInfos); if (keyInfoEntries.length > 1) { @@ -78,17 +98,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { return [keyId, secretStorageKeys[keyId]]; } - const inputToKey = async ({ passphrase, recoveryKey }) => { - if (passphrase) { - return deriveKey( - passphrase, - keyInfo.passphrase.salt, - keyInfo.passphrase.iterations, - ); - } else { - return decodeRecoveryKey(recoveryKey); + if (dehydrationCache.key) { + if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) { + cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo); + return [keyId, dehydrationCache.key]; } - }; + } + + if (nonInteractive) { + throw new Error("Could not unlock non-interactively"); + } + + const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", AccessSecretStorageDialog, /* props= */ @@ -118,14 +139,56 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); // Save to cache to avoid future prompts in the current session - cacheSecretStorageKey(keyId, key); + cacheSecretStorageKey(keyId, key, keyInfo); return [keyId, key]; } -function cacheSecretStorageKey(keyId, key) { +export async function getDehydrationKey(keyInfo, checkFunc) { + const inputToKey = makeInputToKey(keyInfo); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + /* props= */ + { + keyInfo, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + try { + checkFunc(key); + return true; + } catch (e) { + return false; + } + }, + }, + /* className= */ null, + /* isPriorityModal= */ false, + /* isStaticModal= */ false, + /* options= */ { + onBeforeClose: async (reason) => { + if (reason === "backgroundClick") { + return confirmToDismiss(); + } + return true; + }, + }, + ); + const [input] = await finished; + if (!input) { + throw new AccessCancelledError(); + } + const key = await inputToKey(input); + + // need to copy the key because rehydration (unpickling) will clobber it + dehydrationCache = {key: new Uint8Array(key), keyInfo}; + + return key; +} + +function cacheSecretStorageKey(keyId, key, keyInfo) { if (isCachingAllowed()) { secretStorageKeys[keyId] = key; + secretStorageKeyInfo[keyId] = keyInfo; } } @@ -176,6 +239,7 @@ export const crossSigningCallbacks = { getSecretStorageKey, cacheSecretStorageKey, onSecretRequested, + getDehydrationKey, }; export async function promptForBackupPassphrase() { @@ -262,6 +326,18 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f await cli.bootstrapSecretStorage({ getKeyBackupPassphrase: promptForBackupPassphrase, }); + + const keyId = Object.keys(secretStorageKeys)[0]; + if (keyId && SettingsStore.getValue("feature_dehydration")) { + const dehydrationKeyInfo = + secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase + ? {passphrase: secretStorageKeyInfo[keyId].passphrase} + : {}; + console.log("Setting dehydration key"); + await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); + } else { + console.log("Not setting dehydration key: no SSSS key found"); + } } // `return await` needed here to ensure `finally` block runs after the @@ -272,6 +348,57 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f secretStorageBeingAccessed = false; if (!isCachingAllowed()) { secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } +} + +// FIXME: this function name is a bit of a mouthful +export async function tryToUnlockSecretStorageWithDehydrationKey(client) { + const key = dehydrationCache.key; + let restoringBackup = false; + if (key && await client.isSecretStorageReady()) { + console.log("Trying to set up cross-signing using dehydration key"); + secretStorageBeingAccessed = true; + nonInteractive = true; + try { + await client.checkOwnCrossSigningTrust(); + + // we also need to set a new dehydrated device to replace the + // device we rehydrated + const dehydrationKeyInfo = + dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase + ? {passphrase: dehydrationCache.keyInfo.passphrase} + : {}; + await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device"); + + // and restore from backup + const backupInfo = await client.getKeyBackupVersion(); + if (backupInfo) { + restoringBackup = true; + // don't await, because this can take a long time + client.restoreKeyBackupWithSecretStorage(backupInfo) + .finally(() => { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + }); + } + } finally { + dehydrationCache = {}; + // the secret storage cache is needed for restoring from backup, so + // don't clear it yet if we're restoring from backup + if (!restoringBackup) { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } } } } diff --git a/src/SendHistoryManager.js b/src/SendHistoryManager.ts similarity index 62% rename from src/SendHistoryManager.js rename to src/SendHistoryManager.ts index d9955727a4..e9268ad642 100644 --- a/src/SendHistoryManager.js +++ b/src/SendHistoryManager.ts @@ -16,12 +16,21 @@ limitations under the License. */ import {clamp} from "lodash"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; + +import {SerializedPart} from "./editor/parts"; +import EditorModel from "./editor/model"; + +interface IHistoryItem { + parts: SerializedPart[]; + replyEventId?: string; +} export default class SendHistoryManager { - history: Array = []; + history: Array = []; prefix: string; - lastIndex: number = 0; // used for indexing the storage - currentIndex: number = 0; // used for indexing the loaded validated history Array + lastIndex = 0; // used for indexing the storage + currentIndex = 0; // used for indexing the loaded validated history Array constructor(roomId: string, prefix: string) { this.prefix = prefix + roomId; @@ -32,8 +41,7 @@ export default class SendHistoryManager { while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) { try { - const serializedParts = JSON.parse(itemJSON); - this.history.push(serializedParts); + this.history.push(JSON.parse(itemJSON)); } catch (e) { console.warn("Throwing away unserialisable history", e); break; @@ -45,15 +53,22 @@ export default class SendHistoryManager { this.currentIndex = this.lastIndex + 1; } - save(editorModel: Object) { - const serializedParts = editorModel.serializeParts(); - this.history.push(serializedParts); - this.currentIndex = this.history.length; - this.lastIndex += 1; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts)); + static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem { + return { + parts: model.serializeParts(), + replyEventId: replyEvent ? replyEvent.getId() : undefined, + }; } - getItem(offset: number): ?HistoryItem { + save(editorModel: EditorModel, replyEvent?: MatrixEvent) { + const item = SendHistoryManager.createItem(editorModel, replyEvent); + this.history.push(item); + this.currentIndex = this.history.length; + this.lastIndex += 1; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item)); + } + + getItem(offset: number): IHistoryItem { this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1); return this.history[this.currentIndex]; } diff --git a/src/TextForEvent.js b/src/TextForEvent.js index a76c1f59e6..34d40bf1fd 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -14,12 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ import {MatrixClientPeg} from './MatrixClientPeg'; -import CallHandler from './CallHandler'; import { _t } from './languageHandler'; import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; import SettingsStore from "./settings/SettingsStore"; -import {WidgetType} from "./widgets/WidgetType"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; function textForMemberEvent(ev) { @@ -29,7 +27,6 @@ function textForMemberEvent(ev) { const prevContent = ev.getPrevContent(); const content = ev.getContent(); - const ConferenceHandler = CallHandler.getConferenceHandler(); const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; switch (content.membership) { case 'invite': { @@ -44,11 +41,7 @@ function textForMemberEvent(ev) { return _t('%(targetName)s accepted an invitation.', {targetName}); } } else { - if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { - return _t('%(senderName)s requested a VoIP conference.', {senderName}); - } else { - return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); - } + return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); } } case 'ban': @@ -85,17 +78,11 @@ function textForMemberEvent(ev) { } } else { if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); - if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { - return _t('VoIP conference started.'); - } else { - return _t('%(targetName)s joined the room.', {targetName}); - } + return _t('%(targetName)s joined the room.', {targetName}); } case 'leave': if (ev.getSender() === ev.getStateKey()) { - if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { - return _t('VoIP conference finished.'); - } else if (prevContent.membership === "invite") { + if (prevContent.membership === "invite") { return _t('%(targetName)s rejected the invitation.', {targetName}); } else { return _t('%(targetName)s left the room.', {targetName}); @@ -476,10 +463,6 @@ function textForWidgetEvent(event) { const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); const {name, type, url} = event.getContent() || {}; - if (WidgetType.JITSI.matches(type) || WidgetType.JITSI.matches(prevType)) { - return textForJitsiWidgetEvent(event, senderName, url, prevUrl); - } - let widgetName = name || prevName || type || prevType || ''; // Apply sentence case to widget name if (widgetName && widgetName.length > 0) { @@ -505,24 +488,6 @@ function textForWidgetEvent(event) { } } -function textForJitsiWidgetEvent(event, senderName, url, prevUrl) { - if (url) { - if (prevUrl) { - return _t('Group call modified by %(senderName)s', { - senderName, - }); - } else { - return _t('Group call started by %(senderName)s', { - senderName, - }); - } - } else { - return _t('Group call ended by %(senderName)s', { - senderName, - }); - } -} - function textForMjolnirEvent(event) { const senderName = event.getSender(); const {entity: prevEntity} = event.getPrevContent(); diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js deleted file mode 100644 index 00309d252c..0000000000 --- a/src/ToWidgetPostMessageApi.js +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// const OUTBOUND_API_NAME = 'toWidget'; - -// Initiate requests using the "toWidget" postMessage API and handle responses -// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a -// response field -export default class ToWidgetPostMessageApi { - constructor(timeoutMs) { - this._timeoutMs = timeoutMs || 5000; // default to 5s timer - this._counter = 0; - this._requestMap = { - // $ID: {resolve, reject} - }; - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - onPostMessage(ev) { - // THIS IS ALL UNSAFE EXECUTION. - // We do not verify who the sender of `ev` is! - const payload = ev.data; - // NOTE: Workaround for running in a mobile WebView where a - // postMessage immediately triggers this callback even though it is - // not the response. - if (payload.response === undefined) { - return; - } - const promise = this._requestMap[payload.requestId]; - if (!promise) { - return; - } - delete this._requestMap[payload.requestId]; - promise.resolve(payload); - } - - // Initiate outbound requests (toWidget) - exec(action, targetWindow, targetOrigin) { - targetWindow = targetWindow || window.parent; // default to parent window - targetOrigin = targetOrigin || "*"; - this._counter += 1; - action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter; - - return new Promise((resolve, reject) => { - this._requestMap[action.requestId] = {resolve, reject}; - targetWindow.postMessage(action, targetOrigin); - - if (this._timeoutMs > 0) { - setTimeout(() => { - if (!this._requestMap[action.requestId]) { - return; - } - console.error("postMessage request timed out. Sent object: " + JSON.stringify(action), - this._requestMap); - this._requestMap[action.requestId].reject(new Error("Timed out")); - delete this._requestMap[action.requestId]; - }, this._timeoutMs); - } - }); - } -} diff --git a/src/VectorConferenceHandler.js b/src/VectorConferenceHandler.js deleted file mode 100644 index c10bc659ae..0000000000 --- a/src/VectorConferenceHandler.js +++ /dev/null @@ -1,135 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {createNewMatrixCall as jsCreateNewMatrixCall, Room} from "matrix-js-sdk"; -import CallHandler from './CallHandler'; -import {MatrixClientPeg} from "./MatrixClientPeg"; - -// FIXME: this is Element specific code, but will be removed shortly when we -// switch over to Jitsi entirely for video conferencing. - -// FIXME: This currently forces Element to try to hit the matrix.org AS for -// conferencing. This is bad because it prevents people running their own ASes -// from being used. This isn't permanent and will be customisable in the future: -// see the proposal at docs/conferencing.md for more info. -const USER_PREFIX = "fs_"; -const DOMAIN = "matrix.org"; - -export function ConferenceCall(matrixClient, groupChatRoomId) { - this.client = matrixClient; - this.groupRoomId = groupChatRoomId; - this.confUserId = getConferenceUserIdForRoom(this.groupRoomId); -} - -ConferenceCall.prototype.setup = function() { - const self = this; - return this._joinConferenceUser().then(function() { - return self._getConferenceUserRoom(); - }).then(function(room) { - // return a call for *this* room to be placed. We also tack on - // confUserId to speed up lookups (else we'd need to loop every room - // looking for a 1:1 room with this conf user ID!) - const call = jsCreateNewMatrixCall(self.client, room.roomId); - call.confUserId = self.confUserId; - call.groupRoomId = self.groupRoomId; - return call; - }); -}; - -ConferenceCall.prototype._joinConferenceUser = function() { - // Make sure the conference user is in the group chat room - const groupRoom = this.client.getRoom(this.groupRoomId); - if (!groupRoom) { - return Promise.reject("Bad group room ID"); - } - const member = groupRoom.getMember(this.confUserId); - if (member && member.membership === "join") { - return Promise.resolve(); - } - return this.client.invite(this.groupRoomId, this.confUserId); -}; - -ConferenceCall.prototype._getConferenceUserRoom = function() { - // Use an existing 1:1 with the conference user; else make one - const rooms = this.client.getRooms(); - let confRoom = null; - for (let i = 0; i < rooms.length; i++) { - const confUser = rooms[i].getMember(this.confUserId); - if (confUser && confUser.membership === "join" && - rooms[i].getJoinedMemberCount() === 2) { - confRoom = rooms[i]; - break; - } - } - if (confRoom) { - return Promise.resolve(confRoom); - } - return this.client.createRoom({ - preset: "private_chat", - invite: [this.confUserId], - }).then(function(res) { - return new Room(res.room_id, null, MatrixClientPeg.get().getUserId()); - }); -}; - -/** - * Check if this user ID is in fact a conference bot. - * @param {string} userId The user ID to check. - * @return {boolean} True if it is a conference bot. - */ -export function isConferenceUser(userId) { - if (userId.indexOf("@" + USER_PREFIX) !== 0) { - return false; - } - const base64part = userId.split(":")[0].substring(1 + USER_PREFIX.length); - if (base64part) { - const decoded = new Buffer(base64part, "base64").toString(); - // ! $STUFF : $STUFF - return /^!.+:.+/.test(decoded); - } - return false; -} - -export function getConferenceUserIdForRoom(roomId) { - // abuse browserify's core node Buffer support (strip padding ='s) - const base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, ""); - return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN; -} - -export function createNewMatrixCall(client, roomId) { - const confCall = new ConferenceCall( - client, roomId, - ); - return confCall.setup(); -} - -export function getConferenceCallForRoom(roomId) { - // search for a conference 1:1 call for this group chat room ID - const activeCall = CallHandler.getAnyActiveCall(); - if (activeCall && activeCall.confUserId) { - const thisRoomConfUserId = getConferenceUserIdForRoom( - roomId, - ); - if (thisRoomConfUserId === activeCall.confUserId) { - return activeCall; - } - } - return null; -} - -// TODO: Document this. -export const slot = 'conference'; diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js deleted file mode 100644 index c68e926ac1..0000000000 --- a/src/WidgetMessaging.js +++ /dev/null @@ -1,212 +0,0 @@ -/* -Copyright 2017 New Vector Ltd -Copyright 2019 Travis Ralston - -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. -*/ - -/* -* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for -* spec. details / documentation. -*/ - -import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; -import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; -import Modal from "./Modal"; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import SettingsStore from "./settings/SettingsStore"; -import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; -import WidgetUtils from "./utils/WidgetUtils"; -import {KnownWidgetActions} from "./widgets/WidgetApi"; - -if (!global.mxFromWidgetMessaging) { - global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); - global.mxFromWidgetMessaging.start(); -} -if (!global.mxToWidgetMessaging) { - global.mxToWidgetMessaging = new ToWidgetPostMessageApi(); - global.mxToWidgetMessaging.start(); -} - -const OUTBOUND_API_NAME = 'toWidget'; - -export default class WidgetMessaging { - /** - * @param {string} widgetId The widget's ID - * @param {string} wurl The raw URL of the widget as in the event (the 'wURL') - * @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL - * or a different URL of the clients choosing if it is using its own impl). - * @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget - * @param {object} target Where widget messages should be sent (eg. the iframe object) - */ - constructor(widgetId, wurl, renderedUrl, isUserWidget, target) { - this.widgetId = widgetId; - this.wurl = wurl; - this.renderedUrl = renderedUrl; - this.isUserWidget = isUserWidget; - this.target = target; - this.fromWidget = global.mxFromWidgetMessaging; - this.toWidget = global.mxToWidgetMessaging; - this._onOpenIdRequest = this._onOpenIdRequest.bind(this); - this.start(); - } - - messageToWidget(action) { - action.widgetId = this.widgetId; // Required to be sent for all outbound requests - - return this.toWidget.exec(action, this.target).then((data) => { - // Check for errors and reject if found - if (data.response === undefined) { // null is valid - throw new Error("Missing 'response' field"); - } - if (data.response && data.response.error) { - const err = data.response.error; - const msg = String(err.message ? err.message : "An error was returned"); - if (err._error) { - console.error(err._error); - } - // Potential XSS attack if 'msg' is not appropriately sanitized, - // as it is untrusted input by our parent window (which we assume is Element). - // We can't aggressively sanitize [A-z0-9] since it might be a translation. - throw new Error(msg); - } - // Return the response field for the request - return data.response; - }); - } - - /** - * Tells the widget that the client is ready to handle further widget requests. - * @returns {Promise<*>} Resolves after the widget has acknowledged the ready message. - */ - flagReadyToContinue() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.ClientReady, - }); - } - - /** - * Tells the widget that it should terminate now. - * @returns {Promise<*>} Resolves when widget has acknowledged the message. - */ - terminate() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.Terminate, - }); - } - - /** - * Request a screenshot from a widget - * @return {Promise} To be resolved with screenshot data when it has been generated - */ - getScreenshot() { - console.log('Requesting screenshot for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "screenshot", - }) - .catch((error) => new Error("Failed to get screenshot: " + error.message)) - .then((response) => response.screenshot); - } - - /** - * Request capabilities required by the widget - * @return {Promise} To be resolved with an array of requested widget capabilities - */ - getCapabilities() { - console.log('Requesting capabilities for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "capabilities", - }).then((response) => { - console.log('Got capabilities for', this.widgetId, response.capabilities); - return response.capabilities; - }); - } - - sendVisibility(visible) { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "visibility", - visible, - }) - .catch((error) => { - console.error("Failed to send visibility: ", error); - }); - } - - start() { - this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.addListener("get_openid", this._onOpenIdRequest); - } - - stop() { - this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.removeListener("get_openid", this._onOpenIdRequest); - } - - async _onOpenIdRequest(ev, rawEv) { - if (ev.widgetId !== this.widgetId) return; // not interesting - - const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget); - - const settings = SettingsStore.getValue("widgetOpenIDPermissions"); - if (settings.deny && settings.deny.includes(widgetSecurityKey)) { - this.fromWidget.sendResponse(rawEv, {state: "blocked"}); - return; - } - if (settings.allow && settings.allow.includes(widgetSecurityKey)) { - const responseBody = {state: "allowed"}; - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - this.fromWidget.sendResponse(rawEv, responseBody); - return; - } - - // Confirm that we received the request - this.fromWidget.sendResponse(rawEv, {state: "request"}); - - // Actually ask for permission to send the user's data - Modal.createTrackedDialog("OpenID widget permissions", '', - WidgetOpenIDPermissionsDialog, { - widgetUrl: this.wurl, - widgetId: this.widgetId, - isUserWidget: this.isUserWidget, - - onFinished: async (confirm) => { - const responseBody = { - // Legacy (early draft) fields - success: confirm, - - // New style MSC1960 fields - state: confirm ? "allowed" : "blocked", - original_request_id: ev.requestId, // eslint-disable-line camelcase - }; - if (confirm) { - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - } - this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "openid_credentials", - data: responseBody, - }).catch((error) => { - console.error("Failed to send OpenID credentials: ", error); - }); - }, - }, - ); - } -} diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js deleted file mode 100644 index 9114e12137..0000000000 --- a/src/WidgetMessagingEndpoint.js +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - - -/** - * Represents mapping of widget instance to URLs for trusted postMessage communication. - */ -export default class WidgetMessageEndpoint { - /** - * Mapping of widget instance to URL for trusted postMessage communication. - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin. - */ - constructor(widgetId, endpointUrl) { - if (!widgetId) { - throw new Error("No widgetId specified in widgetMessageEndpoint constructor"); - } - if (!endpointUrl) { - throw new Error("No endpoint specified in widgetMessageEndpoint constructor"); - } - this.widgetId = widgetId; - this.endpointUrl = endpointUrl; - } -} diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b1dbb56a01..434b931296 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -166,7 +166,8 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn const onKeyDownHandler = useCallback((ev) => { let handled = false; - if (handleHomeEnd) { + // Don't interfere with input default keydown behaviour + if (handleHomeEnd && ev.target.tagName !== "INPUT") { // check if we actually have any items switch (ev.key) { case Key.HOME: diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index cc2a1769c7..e756d948e5 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -28,6 +28,9 @@ interface IProps extends Omit, "onKeyDown"> { const Toolbar: React.FC = ({children, ...props}) => { const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const target = ev.target as HTMLElement; + // Don't interfere with input default keydown behaviour + if (target.tagName === "INPUT") return; + let handled = true; // HOME and END are handled by RovingTabIndexProvider diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index f3b52da141..00aad2a0ce 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -31,7 +31,7 @@ import AccessibleButton from "../../../../components/views/elements/AccessibleBu import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; -import { isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; +import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; @@ -87,10 +87,16 @@ export default class CreateSecretStorageDialog extends React.PureComponent { canUploadKeysWithPasswordOnly: null, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY, canSkip: !isSecureBackupRequired(), }; + const setupMethods = getSecureBackupSetupMethods(); + if (setupMethods.includes("key")) { + this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_KEY; + } else { + this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_PASSPHRASE; + } + this._passphraseField = createRef(); this._fetchBackupInfo(); @@ -441,39 +447,55 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); } + _renderOptionKey() { + return ( + +
+ + {_t("Generate a Security Key")} +
+
{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}
+
+ ); + } + + _renderOptionPassphrase() { + return ( + +
+ + {_t("Enter a Security Phrase")} +
+
{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}
+
+ ); + } + _renderPhaseChooseKeyPassphrase() { + const setupMethods = getSecureBackupSetupMethods(); + const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null; + const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null; + return

{_t( "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", )}

- -
- - {_t("Generate a Security Key")} -
-
{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}
-
- -
- - {_t("Enter a Security Phrase")} -
-
{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}
-
+ {optionKey} + {optionPassphrase}
{_t('Attach files from chat or just drag and drop them anywhere in a room.')}

); + const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); + if (this.state.timelineSet) { // console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " + // "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); @@ -232,6 +235,7 @@ class FilePanel extends React.Component { previousPhase={RightPanelPhases.RoomSummary} withoutScrollContainer > + { protected readonly _matrixClient: MatrixClient; protected readonly _roomView: React.RefObject; protected readonly _resizeContainer: React.RefObject; - protected readonly _sessionStore: sessionStore; - protected readonly _sessionStoreToken: { remove: () => void }; protected readonly _compactLayoutWatcherRef: string; protected resizer: Resizer; @@ -172,12 +164,6 @@ class LoggedInView extends React.Component { document.addEventListener('keydown', this._onNativeKeyDown, false); - this._sessionStore = sessionStore; - this._sessionStoreToken = this._sessionStore.addListener( - this._setStateFromSessionStore, - ); - this._setStateFromSessionStore(); - this._updateServerNoticeEvents(); this._matrixClient.on("accountData", this.onAccountData); @@ -206,9 +192,6 @@ class LoggedInView extends React.Component { this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); SettingsStore.unwatchSetting(this._compactLayoutWatcherRef); - if (this._sessionStoreToken) { - this._sessionStoreToken.remove(); - } this.resizer.detach(); } @@ -229,14 +212,6 @@ class LoggedInView extends React.Component { return this._roomView.current.canResetTimeline(); }; - _setStateFromSessionStore = () => { - if (this._sessionStore.getCachedPassword()) { - showSetPasswordToast(); - } else { - hideSetPasswordToast(); - } - }; - _createResizer() { const classNames = { handle: "mx_ResizeHandle", @@ -637,7 +612,6 @@ class LoggedInView extends React.Component { viaServers={this.props.viaServers} key={this.props.currentRoomId || 'roomview'} disabled={this.props.middleDisabled} - ConferenceHandler={this.props.ConferenceHandler} resizeNotifier={this.props.resizeNotifier} />; break; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index ea1f424af6..4f5489d796 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -30,7 +30,7 @@ import 'what-input'; import Analytics from "../../Analytics"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; import * as RoomListSorter from "../../RoomListSorter"; @@ -80,6 +80,7 @@ import { leaveRoomBehaviour } from "../../utils/membership"; import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore"; import {UIFeature} from "../../settings/UIFeature"; +import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; /** constants for MatrixChat.state.view */ export enum Views { @@ -148,7 +149,6 @@ interface IRoomInfo { interface IProps { // TODO type things better config: Record; serverConfig?: ValidatedServerConfig; - ConferenceHandler?: any; onNewScreen: (screen: string, replaceLast: boolean) => void; enableGuest?: boolean; // the queryParams extracted from the [real] query-string of the URI @@ -290,7 +290,7 @@ export default class MatrixChat extends React.PureComponent { // When the session loads it'll be detected as soft logged out and a dispatch // will be sent out to say that, triggering this MatrixChat to show the soft // logout page. - Lifecycle.loadSession({}); + Lifecycle.loadSession(); } this.accountPassword = null; @@ -670,9 +670,6 @@ export default class MatrixChat extends React.PureComponent { case 'view_home_page': this.viewHome(); break; - case 'view_set_mxid': - this.setMxId(payload); - break; case 'view_start_chat_or_reuse': this.chatCreateOrReuse(payload.user_id); break; @@ -985,37 +982,19 @@ export default class MatrixChat extends React.PureComponent { }); } - private setMxId(payload) { - const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); - const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { - homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(), - onFinished: (submitted, credentials) => { - if (!submitted) { - dis.dispatch({ - action: 'cancel_after_sync_prepared', - }); - if (payload.go_home_on_cancel) { - dis.dispatch({ - action: 'view_home_page', - }); - } - return; - } - MatrixClientPeg.setJustRegisteredUserId(credentials.user_id); - this.onRegistered(credentials); - }, - onDifferentServerClicked: (ev) => { - dis.dispatch({action: 'start_registration'}); - close(); - }, - onLoginClick: (ev) => { - dis.dispatch({action: 'start_login'}); - close(); - }, - }).close; - } - private async createRoom(defaultPublic = false) { + const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); + if (communityId) { + // double check the user will have permission to associate this room with the community + if (!CommunityPrototypeStore.instance.isAdminOf(communityId)) { + Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, { + title: _t("Cannot create rooms in this community"), + description: _t("You do not have permission to create rooms in this community."), + }); + return; + } + } + const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic }); @@ -1802,12 +1781,12 @@ export default class MatrixChat extends React.PureComponent { this.showScreen("forgot_password"); }; - onRegisterFlowComplete = (credentials: object, password: string) => { + onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string) => { return this.onUserCompletedLoginFlow(credentials, password); }; // returns a promise which resolves to the new MatrixClient - onRegistered(credentials: object) { + onRegistered(credentials: IMatrixClientCreds) { return Lifecycle.setLoggedIn(credentials); } @@ -1843,7 +1822,12 @@ export default class MatrixChat extends React.PureComponent { } else { subtitle = `${this.subTitleStatus} ${subtitle}`; } - document.title = `${SdkConfig.get().brand} ${subtitle}`; + + const title = `${SdkConfig.get().brand} ${subtitle}`; + + if (document.title !== title) { + document.title = title; + } } updateStatusIndicator(state: string, prevState: string) { @@ -1888,7 +1872,7 @@ export default class MatrixChat extends React.PureComponent { * Note: SSO users (and any others using token login) currently do not pass through * this, as they instead jump straight into the app after `attemptTokenLogin`. */ - onUserCompletedLoginFlow = async (credentials: object, password: string) => { + onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string) => { this.accountPassword = password; // self-destruct the password after 5mins if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 6c6d8700a5..021cdb438d 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -1,9 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 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. @@ -162,7 +159,7 @@ export default class RightPanel extends React.Component { } onRoomStateMember(ev, state, member) { - if (member.roomId !== this.props.room.roomId) { + if (!this.props.room || member.roomId !== this.props.room.roomId) { return; } // redraw the badge on the membership list @@ -202,13 +199,19 @@ export default class RightPanel extends React.Component { dis.dispatch({ action: "view_home_page", }); + } else if (this.state.phase === RightPanelPhases.EncryptionPanel && + this.state.verificationRequest && this.state.verificationRequest.pending + ) { + // When the user clicks close on the encryption panel cancel the pending request first if any + this.state.verificationRequest.cancel(); } else { // Otherwise we have got our user from RoomViewStore which means we're being shown // within a room/group, so go back to the member panel if we were in the encryption panel, // or the member list if we were in the member panel... phew. + const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel; dis.dispatch({ action: Action.ViewUser, - member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null, + member: isEncryptionPhase ? this.state.member : null, }); } }; diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 55c6527f06..df580e8de0 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -35,7 +35,7 @@ import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; const MAX_NAME_LENGTH = 80; -const MAX_TOPIC_LENGTH = 160; +const MAX_TOPIC_LENGTH = 800; function track(action) { Analytics.trackEvent('RoomDirectory', action); @@ -497,6 +497,9 @@ export default class RoomDirectory extends React.Component { } let topic = room.topic || ''; + // Additional truncation based on line numbers is done via CSS, + // but to ensure that the DOM is not polluted with a huge string + // we give it a hard limit before rendering. if (topic.length > MAX_TOPIC_LENGTH) { topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 4c418e9994..fcb2d274c1 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -69,7 +69,6 @@ import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; import TintableSvg from "../views/elements/TintableSvg"; -import type * as ConferenceHandler from '../../VectorConferenceHandler'; import {XOR} from "../../@types/common"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; @@ -84,8 +83,6 @@ if (DEBUG) { } interface IProps { - ConferenceHandler?: ConferenceHandler; - threepidInvite: IThreepidInvite, // Any data about the room that would normally come from the homeserver @@ -181,7 +178,6 @@ export interface IState { matrixClientIsReady: boolean; showUrlPreview?: boolean; e2eStatus?: E2EStatus; - displayConfCallNotification?: boolean; rejecting?: boolean; rejectError?: Error; } @@ -488,8 +484,6 @@ export default class RoomView extends React.Component { callState: callState, }); - this.updateConfCallNotification(); - window.addEventListener('beforeunload', this.onPageUnload); if (this.props.resizeNotifier) { this.props.resizeNotifier.on("middlePanelResized", this.onResize); @@ -724,10 +718,6 @@ export default class RoomView extends React.Component { callState = call.call_state; } - // possibly remove the conf call notification if we're now in - // the conf - this.updateConfCallNotification(); - this.setState({ callState: callState, }); @@ -1018,9 +1008,6 @@ export default class RoomView extends React.Component { // rate limited because a power level change will emit an event for every member in the room. private updateRoomMembers = rateLimitedFunc((dueToMember) => { - // a member state changed in this room - // refresh the conf call notification state - this.updateConfCallNotification(); this.updateDMState(); let memberCountInfluence = 0; @@ -1049,30 +1036,6 @@ export default class RoomView extends React.Component { this.setState({isAlone: joinedOrInvitedMemberCount === 1}); } - private updateConfCallNotification() { - const room = this.state.room; - if (!room || !this.props.ConferenceHandler) { - return; - } - const confMember = room.getMember( - this.props.ConferenceHandler.getConferenceUserIdForRoom(room.roomId), - ); - - if (!confMember) { - return; - } - const confCall = this.props.ConferenceHandler.getConferenceCallForRoom(confMember.roomId); - - // A conf call notification should be displayed if there is an ongoing - // conf call but this cilent isn't a part of it. - this.setState({ - displayConfCallNotification: ( - (!confCall || confCall.call_state === "ended") && - confMember.membership === "join" - ), - }); - } - private updateDMState() { const room = this.state.room; if (room.getMyMembership() != "join") { @@ -1127,42 +1090,7 @@ export default class RoomView extends React.Component { room_id: this.getRoomId(), }, }); - - // Don't peek whilst registering otherwise getPendingEventList complains - // Do this by indicating our intention to join - - // XXX: ILAG is disabled for now, - // see https://github.com/vector-im/element-web/issues/8222 dis.dispatch({action: 'require_registration'}); - // dis.dispatch({ - // action: 'will_join', - // }); - - // const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); - // const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { - // homeserverUrl: cli.getHomeserverUrl(), - // onFinished: (submitted, credentials) => { - // if (submitted) { - // this.props.onRegistered(credentials); - // } else { - // dis.dispatch({ - // action: 'cancel_after_sync_prepared', - // }); - // dis.dispatch({ - // action: 'cancel_join', - // }); - // } - // }, - // onDifferentServerClicked: (ev) => { - // dis.dispatch({action: 'start_registration'}); - // close(); - // }, - // onLoginClick: (ev) => { - // dis.dispatch({action: 'start_login'}); - // close(); - // }, - // }).close; - // return; } else { Promise.resolve().then(() => { const signUrl = this.props.threepidInvite?.signUrl; @@ -1681,7 +1609,7 @@ export default class RoomView extends React.Component { if (!this.state.room) { return null; } - return CallHandler.getCallForRoom(this.state.room.roomId); + return CallHandler.sharedInstance().getCallForRoom(this.state.room.roomId); } // this has to be a proper method rather than an unnamed function, @@ -1857,7 +1785,6 @@ export default class RoomView extends React.Component { let aux = null; let previewBar; let hideCancel = false; - let forceHideRightPanel = false; if (this.state.forwardingEvent) { aux = ; } else if (this.state.searching) { @@ -1866,6 +1793,7 @@ export default class RoomView extends React.Component { searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} + isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)} />; } else if (showRoomUpgradeBar) { aux = ; @@ -1901,8 +1829,6 @@ export default class RoomView extends React.Component { { previewBar } ); - } else { - forceHideRightPanel = true; } } else if (hiddenHighlightCount > 0) { aux = ( @@ -1924,9 +1850,7 @@ export default class RoomView extends React.Component { room={this.state.room} fullHeight={false} userId={this.context.credentials.userId} - conferenceHandler={this.props.ConferenceHandler} draggingFile={this.state.draggingFile} - displayConfCallNotification={this.state.displayConfCallNotification} maxHeight={this.state.auxPanelMaxHeight} showApps={this.state.showApps} hideAppsDrawer={false} @@ -2107,7 +2031,7 @@ export default class RoomView extends React.Component { "mx_fadable_faded": this.props.disabled, }); - const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel; + const showRightPanel = this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel ? : null; diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 369d3b7720..17523290b9 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -343,6 +343,7 @@ export default class UserMenu extends React.Component { let secondarySection = null; if (prototypeCommunityName) { + const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); primaryHeader = (
@@ -350,24 +351,36 @@ export default class UserMenu extends React.Component {
); - primaryOptionList = ( - + let settingsOption; + let inviteOption; + if (CommunityPrototypeStore.instance.canInviteTo(communityId)) { + inviteOption = ( + + ); + } + if (CommunityPrototypeStore.instance.isAdminOf(communityId)) { + settingsOption = ( + ); + } + primaryOptionList = ( + + {settingsOption} - + {inviteOption} ); secondarySection = ( diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 2f5064447e..b420ed0872 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -40,11 +40,7 @@ interface IProps { onValidate(result: IValidationResult); } -interface IState { - complexity: zxcvbn.ZXCVBNResult; -} - -class PassphraseField extends PureComponent { +class PassphraseField extends PureComponent { static defaultProps = { label: _td("Password"), labelEnterPassword: _td("Enter password"), @@ -52,14 +48,16 @@ class PassphraseField extends PureComponent { labelAllowedButUnsafe: _td("Password is allowed, but unsafe"), }; - state = { complexity: null }; - - public readonly validate = withValidation({ - description: function() { - const complexity = this.state.complexity; + public readonly validate = withValidation({ + description: function(complexity) { const score = complexity ? complexity.score : 0; return ; }, + deriveData: async ({ value }) => { + if (!value) return null; + const { scorePassword } = await import('../../../utils/PasswordScorer'); + return scorePassword(value); + }, rules: [ { key: "required", @@ -68,28 +66,24 @@ class PassphraseField extends PureComponent { }, { key: "complexity", - test: async function({ value }) { + test: async function({ value }, complexity) { if (!value) { return false; } - const { scorePassword } = await import('../../../utils/PasswordScorer'); - const complexity = scorePassword(value); - this.setState({ complexity }); const safe = complexity.score >= this.props.minScore; const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"]; return allowUnsafe || safe; }, - valid: function() { + valid: function(complexity) { // Unsafe passwords that are valid are only possible through a // configuration flag. We'll print some helper text to signal // to the user that their password is allowed, but unsafe. - if (this.state.complexity.score >= this.props.minScore) { + if (complexity.score >= this.props.minScore) { return _t(this.props.labelStrongPassword); } return _t(this.props.labelAllowedButUnsafe); }, - invalid: function() { - const complexity = this.state.complexity; + invalid: function(complexity) { if (!complexity) { return null; } diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 8fd51d3715..60b043016b 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -23,7 +23,7 @@ import {Action} from "../../../dispatcher/actions"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import BaseAvatar from "./BaseAvatar"; -interface IProps { +interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember; fallbackUserId?: string; width: number; diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js deleted file mode 100644 index 090def5e54..0000000000 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ /dev/null @@ -1,304 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, {createRef} from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import classnames from 'classnames'; -import { Key } from '../../../Keyboard'; -import { _t } from '../../../languageHandler'; -import { SAFE_LOCALPART_REGEX } from '../../../Registration'; - -// The amount of time to wait for further changes to the input username before -// sending a request to the server -const USERNAME_CHECK_DEBOUNCE_MS = 250; - -/* - * Prompt the user to set a display name. - * - * On success, `onFinished(true, newDisplayName)` is called. - */ -export default class SetMxIdDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - // Called when the user requests to register with a different homeserver - onDifferentServerClicked: PropTypes.func.isRequired, - // Called if the user wants to switch to login instead - onLoginClick: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - this._input_value = createRef(); - this._uiAuth = createRef(); - - this.state = { - // The entered username - username: '', - // Indicate ongoing work on the username - usernameBusy: false, - // Indicate error with username - usernameError: '', - // Assume the homeserver supports username checking until "M_UNRECOGNIZED" - usernameCheckSupport: true, - - // Whether the auth UI is currently being used - doingUIAuth: false, - // Indicate error with auth - authError: '', - }; - } - - componentDidMount() { - this._input_value.current.select(); - - this._matrixClient = MatrixClientPeg.get(); - } - - onValueChange = ev => { - this.setState({ - username: ev.target.value, - usernameBusy: true, - usernameError: '', - }, () => { - if (!this.state.username || !this.state.usernameCheckSupport) { - this.setState({ - usernameBusy: false, - }); - return; - } - - // Debounce the username check to limit number of requests sent - if (this._usernameCheckTimeout) { - clearTimeout(this._usernameCheckTimeout); - } - this._usernameCheckTimeout = setTimeout(() => { - this._doUsernameCheck().finally(() => { - this.setState({ - usernameBusy: false, - }); - }); - }, USERNAME_CHECK_DEBOUNCE_MS); - }); - }; - - onKeyUp = ev => { - if (ev.key === Key.ENTER) { - this.onSubmit(); - } - }; - - onSubmit = ev => { - if (this._uiAuth.current) { - this._uiAuth.current.tryContinue(); - } - this.setState({ - doingUIAuth: true, - }); - }; - - _doUsernameCheck() { - // We do a quick check ahead of the username availability API to ensure the - // user ID roughly looks okay from a Matrix perspective. - if (!SAFE_LOCALPART_REGEX.test(this.state.username)) { - this.setState({ - usernameError: _t("A username can only contain lower case letters, numbers and '=_-./'"), - }); - return Promise.reject(); - } - - // Check if username is available - return this._matrixClient.isUsernameAvailable(this.state.username).then( - (isAvailable) => { - if (isAvailable) { - this.setState({usernameError: ''}); - } - }, - (err) => { - // Indicate whether the homeserver supports username checking - const newState = { - usernameCheckSupport: err.errcode !== "M_UNRECOGNIZED", - }; - console.error('Error whilst checking username availability: ', err); - switch (err.errcode) { - case "M_USER_IN_USE": - newState.usernameError = _t('Username not available'); - break; - case "M_INVALID_USERNAME": - newState.usernameError = _t( - 'Username invalid: %(errMessage)s', - { errMessage: err.message}, - ); - break; - case "M_UNRECOGNIZED": - // This homeserver doesn't support username checking, assume it's - // fine and rely on the error appearing in registration step. - newState.usernameError = ''; - break; - case undefined: - newState.usernameError = _t('Something went wrong!'); - break; - default: - newState.usernameError = _t( - 'An error occurred: %(error_string)s', - { error_string: err.message }, - ); - break; - } - this.setState(newState); - }, - ); - } - - _generatePassword() { - return Math.random().toString(36).slice(2); - } - - _makeRegisterRequest = auth => { - // Not upgrading - changing mxids - const guestAccessToken = null; - if (!this._generatedPassword) { - this._generatedPassword = this._generatePassword(); - } - return this._matrixClient.register( - this.state.username, - this._generatedPassword, - undefined, // session id: included in the auth dict already - auth, - {}, - guestAccessToken, - ); - }; - - _onUIAuthFinished = (success, response) => { - this.setState({ - doingUIAuth: false, - }); - - if (!success) { - this.setState({ authError: response.message }); - return; - } - - this.props.onFinished(true, { - userId: response.user_id, - deviceId: response.device_id, - homeserverUrl: this._matrixClient.getHomeserverUrl(), - identityServerUrl: this._matrixClient.getIdentityServerUrl(), - accessToken: response.access_token, - password: this._generatedPassword, - }); - }; - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); - - let auth; - if (this.state.doingUIAuth) { - auth = ; - } - const inputClasses = classnames({ - "mx_SetMxIdDialog_input": true, - "error": Boolean(this.state.usernameError), - }); - - let usernameIndicator = null; - if (this.state.usernameBusy) { - usernameIndicator =
{_t("Checking...")}
; - } else { - const usernameAvailable = this.state.username && - this.state.usernameCheckSupport && !this.state.usernameError; - const usernameIndicatorClasses = classnames({ - "error": Boolean(this.state.usernameError), - "success": usernameAvailable, - }); - usernameIndicator =
- { usernameAvailable ? _t('Username available') : this.state.usernameError } -
; - } - - let authErrorIndicator = null; - if (this.state.authError) { - authErrorIndicator =
- { this.state.authError } -
; - } - const canContinue = this.state.username && - !this.state.usernameError && - !this.state.usernameBusy; - - return ( - -
-
- -
- { usernameIndicator } -

- { _t( - 'This will be your account name on the ' + - 'homeserver, or you can pick a different server.', - {}, - { - 'span': { this.props.homeserverUrl }, - 'a': (sub) => { sub }, - }, - ) } -

-

- { _t( - 'If you already have a Matrix account you can log in instead.', - {}, - { 'a': (sub) => { sub } }, - ) } -

- { auth } - { authErrorIndicator } -
-
- -
-
- ); - } -} diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js deleted file mode 100644 index 3649190ac9..0000000000 --- a/src/components/views/dialogs/SetPasswordDialog.js +++ /dev/null @@ -1,128 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; - -const WarmFuzzy = function(props) { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - let title = _t('You have successfully set a password!'); - if (props.didSetEmail) { - title = _t('You have successfully set a password and an email address!'); - } - const advice = _t('You can now return to your account after signing out, and sign in on other devices.'); - let extraAdvice = null; - if (!props.didSetEmail) { - extraAdvice = _t('Remember, you can always set an email address in user settings if you change your mind.'); - } - - return -
-

- { advice } -

-

- { extraAdvice } -

-
-
- -
-
; -}; - -/** - * Prompt the user to set a password - * - * On success, `onFinished()` when finished - */ -export default class SetPasswordDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - - state = { - error: null, - }; - - _onPasswordChanged = res => { - Modal.createDialog(WarmFuzzy, { - didSetEmail: res.didSetEmail, - onFinished: () => { - this.props.onFinished(); - }, - }); - }; - - _onPasswordChangeError = err => { - let errMsg = err.error || ""; - if (err.httpStatus === 403) { - errMsg = _t('Failed to change password. Is your password correct?'); - } else if (err.httpStatus) { - errMsg += ' ' + _t( - '(HTTP status %(httpStatus)s)', - { httpStatus: err.httpStatus }, - ); - } - this.setState({ - error: errMsg, - }); - }; - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const ChangePassword = sdk.getComponent('views.settings.ChangePassword'); - - return ( - -
-

- { _t('This will allow you to return to your account after signing out, and sign in on other sessions.') } -

- -
- { this.state.error } -
-
-
- ); - } -} diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.js b/src/components/views/dialogs/security/AccessSecretStorageDialog.js index 85ace249a3..21655e7fd4 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.js @@ -289,7 +289,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent { content =

{_t("Use your Security Key to continue.")}

- +
diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 0388c565ad..29e79dc396 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -62,7 +62,8 @@ export default class AccessibleTooltipButton extends React.PureComponent { - // Append scalar_token as a query param if not already present - this._scalarClient.scalarToken = token; - 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); - // u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options - u.search = undefined; - u.query = params; - } - - this.setState({ - error: null, - widgetUrl: u.format(), - initialising: false, - }); - - // Fetch page title from remote content if not already set - if (!this.state.widgetPageTitle && params.url) { - this._fetchWidgetTitle(params.url); - } - }, (err) => { - console.error("Failed to get scalar_token", err); - this.setState({ - error: err.message, - initialising: false, - }); + _startWidget() { + this._sgWidget.prepare().then(() => { + this.setState({initialising: false}); }); } + _iframeRefChange = (ref) => { + this.iframe = ref; + if (ref) { + this._sgWidget.start(ref); + } else { + this._resetWidget(this.props); + } + }; + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase 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) { - this.setScalarToken(); + this._resetWidget(nextProps); } } @@ -287,9 +179,9 @@ export default class AppTile extends React.Component { loading: true, }); } - // Fetch IM token now that we're showing if we already have permission to load + // Start the widget now that we're showing if we already have permission to load if (this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } } @@ -319,7 +211,14 @@ export default class AppTile extends React.Component { } _onSnapshotClick() { - WidgetUtils.snapshotWidget(this.props.app); + this._sgWidget.widgetApi.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); } /** @@ -327,35 +226,24 @@ export default class AppTile extends React.Component { * @private * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - _endWidgetActions() { - let terminationPromise; - - if (this._hasCapability(Capability.ReceiveTerminate)) { - // Wait for widget to terminate within a timeout - const timeout = 2000; - const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id); - terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]); - } else { - terminationPromise = Promise.resolve(); + async _endWidgetActions() { // widget migration dev note: async to maintain signature + // 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/element-web/issues/7351 + if (this.iframe) { + // 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 Element instance is located. + this.iframe.src = 'about:blank'; } - return terminationPromise.finally(() => { - // 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/element-web/issues/7351 - 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 Element instance is located. - this._appFrame.current.src = 'about:blank'; - } + // Delete the widget from the persisted store for good measure. + PersistedElement.destroyElement(this._persistKey); - // Delete the widget from the persisted store for good measure. - PersistedElement.destroyElement(this._persistKey); - }); + this._sgWidget.stop(); } /* If user has permission to modify widgets, delete the widget, @@ -409,73 +297,21 @@ export default class AppTile extends React.Component { this._revokeWidgetPermission(); } - /** - * Called when widget iframe has finished loading - */ - _onLoaded() { - // 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.app.id); - this._setupWidgetMessaging(); - - ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId); + _onWidgetPrepared = () => { this.setState({loading: false}); - } + }; - _setupWidgetMessaging() { - // FIXME: There's probably no reason to do this here: it should probably be done entirely - // in ActiveWidgetStore. - const widgetMessaging = new WidgetMessaging( - this.props.app.id, - this.props.app.url, - 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.app.id} requested capabilities: ` + requestedCapabilities); - requestedCapabilities = requestedCapabilities || []; - - // Allow whitelisted capabilities - let requestedWhitelistCapabilies = []; - - if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) { - requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) { - return this.indexOf(e)>=0; - }, this.props.whitelistCapabilities); - - if (requestedWhitelistCapabilies.length > 0 ) { - console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` + - requestedWhitelistCapabilies, - ); - } - } - - // TODO -- Add UI to warn about and optionally allow requested capabilities - - ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); - - if (this.props.onCapabilityRequest) { - this.props.onCapabilityRequest(requestedCapabilities); - } - - // 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 (WidgetType.JITSI.matches(this.props.app.type)) { - widgetMessaging.flagReadyToContinue(); - } - }).catch((err) => { - console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err); - }); - } + _onWidgetReady = () => { + if (WidgetType.JITSI.matches(this.props.app.type)) { + this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {}); + } + }; _onAction(payload) { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': - if (this._hasCapability('m.sticker')) { + if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { dis.dispatch({action: 'post_sticker_message', data: payload.data}); } else { console.warn('Ignoring sticker message. Invalid capability'); @@ -493,20 +329,6 @@ export default class AppTile extends React.Component { } } - /** - * Set remote content title on AppTile - * @param {string} url Url to check for title - */ - _fetchWidgetTitle(url) { - this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => { - if (widgetPageTitle) { - this.setState({widgetPageTitle: widgetPageTitle}); - } - }, (err) =>{ - console.error("Failed to get page title", err); - }); - } - _grantWidgetPermission() { const roomId = this.props.room.roomId; console.info("Granting permission for widget to load: " + this.props.app.eventId); @@ -516,7 +338,7 @@ export default class AppTile extends React.Component { this.setState({hasPermissionToLoad: true}); // Fetch a token for the integration manager, now that we're allowed to - this.setScalarToken(); + this._startWidget(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -535,6 +357,7 @@ export default class AppTile extends React.Component { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); + this._sgWidget.stop(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -572,40 +395,6 @@ export default class AppTile extends React.Component { } } - /** - * Replace the widget template variables in a url with their values - * - * @param {string} u The URL with template variables - * @param {string} widgetType The widget's type - * - * @returns {string} url with temlate variables replaced - */ - _templatedUrl(u, widgetType: string) { - const targetData = {}; - if (WidgetType.JITSI.matches(widgetType)) { - targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded - } - const myUserId = MatrixClientPeg.get().credentials.userId; - const myUser = MatrixClientPeg.get().getUser(myUserId); - const vars = Object.assign(targetData, 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); - } - /** * Whether we're using a local version of the widget rather than loading the * actual widget URL @@ -615,67 +404,11 @@ export default class AppTile extends React.Component { return WidgetType.JITSI.matches(this.props.app.type); } - /** - * 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 (WidgetType.JITSI.matches(this.props.app.type)) { - console.log("Replacing Jitsi widget URL with local wrapper"); - url = WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: true, - auth: this.props.app.data ? this.props.app.data.auth : null, - }); - url = this._addWurlParams(url); - } else { - url = this._getSafeUrl(this.state.widgetUrl); - } - return this._templatedUrl(url, this.props.app.type); - } - - _getPopoutUrl() { - if (WidgetType.JITSI.matches(this.props.app.type)) { - return this._templatedUrl( - WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: false, - auth: this.props.app.data ? this.props.app.data.auth : null, - }), - this.props.app.type, - ); - } 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), this.props.app.type); - } - } - - _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)) { - safeWidgetUrl = url.format(parsedWidgetUrl); - } - - // Replace all the dollar signs back to dollar signs as they don't affect HTTP at all. - // We also need the dollar signs in-tact for variable substitution. - return safeWidgetUrl.replace(/%24/g, '$'); - } - _getTileTitle() { const name = this.formatAppTileName(); const titleSpacer =  - ; let title = ''; - if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { + if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) { title = this.state.widgetPageTitle; } @@ -698,9 +431,9 @@ export default class AppTile extends React.Component { // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { this._endWidgetActions().then(() => { - if (this._appFrame.current) { + if (this.iframe) { // Reload iframe - this._appFrame.current.src = this._getRenderedUrl(); + this.iframe.src = this._sgWidget.embedUrl; this.setState({}); } }); @@ -708,13 +441,13 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions // eslint-disable-next-line no-self-assign - this._appFrame.current.src = this._appFrame.current.src; + this.iframe.src = this.iframe.src; } _onContextMenuClick = () => { @@ -760,7 +493,7 @@ export default class AppTile extends React.Component { @@ -785,11 +518,11 @@ export default class AppTile extends React.Component { { this.state.loading && loadingElement }