Merge branch 'develop' into katex

This commit is contained in:
Aleks Kissinger 2020-10-10 19:31:46 +01:00
commit aafaf34233
157 changed files with 6534 additions and 4828 deletions

View file

@ -1,50 +1,31 @@
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. # 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/ImageUtils.js
src/Markdown.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/Rooms.js
src/Unread.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/DecryptFile.js
src/utils/DirectoryUtils.js src/utils/DirectoryUtils.js
src/utils/DMRoomMap.js
src/utils/FormattingUtils.js
src/utils/MultiInviter.js src/utils/MultiInviter.js
src/utils/Receipt.js src/utils/Receipt.js
src/Velociraptor.js
test/components/structures/MessagePanel-test.js test/components/structures/MessagePanel-test.js
test/components/views/dialogs/InteractiveAuthDialog-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js
test/mock-clock.js test/mock-clock.js

View file

@ -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) 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) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.0...v3.4.1)

View file

@ -160,8 +160,8 @@ yarn link matrix-js-sdk
yarn install yarn install
``` ```
See the [help for `yarn link`](https://yarnpkg.com/docs/cli/link) for more See the [help for `yarn link`](https://classic.yarnpkg.com/docs/cli/link) for
details about this. more details about this.
Running tests Running tests
============= =============

View file

@ -1,5 +1,10 @@
const en = require("../src/i18n/strings/en_EN"); 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) => { module.exports = jest.fn((opts, cb) => {
const url = opts.url || opts.uri; const url = opts.url || opts.uri;
if (url && url.endsWith("languages.json")) { if (url && url.endsWith("languages.json")) {
@ -8,9 +13,15 @@ module.exports = jest.fn((opts, cb) => {
"fileName": "en_EN.json", "fileName": "en_EN.json",
"label": "English", "label": "English",
}, },
"de": {
"fileName": "de_DE.json",
"label": "German",
},
})); }));
} else if (url && url.endsWith("en_EN.json")) { } else if (url && url.endsWith("en_EN.json")) {
cb(undefined, {status: 200}, JSON.stringify(en)); cb(undefined, {status: 200}, JSON.stringify(en));
} else if (url && url.endsWith("de_DE.json")) {
cb(undefined, {status: 200}, JSON.stringify(de));
} else { } else {
cb(true, {status: 404}, ""); cb(true, {status: 404}, "");
} }

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.4.1", "version": "3.5.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -80,6 +80,7 @@
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.3",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"pako": "^1.0.11", "pako": "^1.0.11",
"parse5": "^5.1.1", "parse5": "^5.1.1",
@ -96,7 +97,7 @@
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
"sanitize-html": "^1.27.1", "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db",
"tar-js": "^0.3.0", "tar-js": "^0.3.0",
"text-encoding-utf-8": "^1.0.2", "text-encoding-utf-8": "^1.0.2",
"url": "^0.11.0", "url": "^0.11.0",
@ -121,7 +122,7 @@
"@babel/preset-typescript": "^7.10.4", "@babel/preset-typescript": "^7.10.4",
"@babel/register": "^7.10.5", "@babel/register": "^7.10.5",
"@babel/traverse": "^7.11.0", "@babel/traverse": "^7.11.0",
"@peculiar/webcrypto": "^1.1.2", "@peculiar/webcrypto": "^1.1.3",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
@ -151,8 +152,9 @@
"eslint-plugin-react": "^7.20.3", "eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^2.5.1", "eslint-plugin-react-hooks": "^2.5.1",
"glob": "^5.0.15", "glob": "^5.0.15",
"jest": "^24.9.0", "jest": "^26.5.2",
"jest-canvas-mock": "^2.2.0", "jest-canvas-mock": "^2.3.0",
"jest-environment-jsdom-sixteen": "^1.0.3",
"lolex": "^5.1.2", "lolex": "^5.1.2",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.2", "matrix-react-test-utils": "^0.2.2",
@ -165,6 +167,7 @@
"walk": "^2.3.14" "walk": "^2.3.14"
}, },
"jest": { "jest": {
"testEnvironment": "./__test-utils__/environment.js",
"testMatch": [ "testMatch": [
"<rootDir>/test/**/*-test.js" "<rootDir>/test/**/*-test.js"
], ],

View file

@ -9,6 +9,9 @@ set -e
cd `dirname $0` 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 for i in matrix-js-sdk
do do
echo "Checking version of $i..." echo "Checking version of $i..."

View file

@ -18,6 +18,8 @@ limitations under the License.
@import "./_font-sizes.scss"; @import "./_font-sizes.scss";
$hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic
:root { :root {
font-size: 10px; font-size: 10px;
} }
@ -260,7 +262,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
font-weight: 300; font-weight: 300;
font-size: $font-15px; font-size: $font-15px;
position: relative; position: relative;
padding: 25px 30px 30px 30px; padding: 24px;
max-height: 80%; max-height: 80%;
box-shadow: 2px 15px 30px 0 $dialog-shadow-color; box-shadow: 2px 15px 30px 0 $dialog-shadow-color;
border-radius: 8px; border-radius: 8px;

View file

@ -81,8 +81,6 @@
@import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @import "./views/dialogs/_RoomUpgradeWarningDialog.scss";
@import "./views/dialogs/_ServerOfflineDialog.scss"; @import "./views/dialogs/_ServerOfflineDialog.scss";
@import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss";
@import "./views/dialogs/_SetMxIdDialog.scss";
@import "./views/dialogs/_SetPasswordDialog.scss";
@import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_SettingsDialog.scss";
@import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_ShareDialog.scss";
@import "./views/dialogs/_SlashCommandHelpDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss";
@ -101,6 +99,7 @@
@import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AccessibleButton.scss";
@import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressSelector.scss";
@import "./views/elements/_AddressTile.scss"; @import "./views/elements/_AddressTile.scss";
@import "./views/elements/_DesktopBuildsNotice.scss";
@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_DirectorySearchBox.scss";
@import "./views/elements/_Dropdown.scss"; @import "./views/elements/_Dropdown.scss";
@import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_EditableItemList.scss";
@ -140,6 +139,7 @@
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss"; @import "./views/messages/_MImageBody.scss";
@import "./views/messages/_MJitsiWidgetEvent.scss";
@import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MNoticeBody.scss";
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MTextBody.scss";

View file

@ -133,6 +133,10 @@ limitations under the License.
.mx_RoomDirectory_topic { .mx_RoomDirectory_topic {
cursor: initial; cursor: initial;
color: $light-fg-color; color: $light-fg-color;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
} }
.mx_RoomDirectory_alias { .mx_RoomDirectory_alias {

View file

@ -17,7 +17,7 @@ limitations under the License.
.mx_TabbedView { .mx_TabbedView {
margin: 0; margin: 0;
padding: 0 0 0 58px; padding: 0 0 0 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute; position: absolute;
@ -25,6 +25,7 @@ limitations under the License.
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
margin-top: 8px;
} }
.mx_TabbedView_tabLabels { .mx_TabbedView_tabLabels {
@ -35,13 +36,13 @@ limitations under the License.
} }
.mx_TabbedView_tabLabel { .mx_TabbedView_tabLabel {
display: flex;
align-items: center;
vertical-align: text-top; vertical-align: text-top;
cursor: pointer; cursor: pointer;
display: block; padding: 8px 0;
border-radius: 3px; border-radius: 8px;
font-size: $font-14px; font-size: $font-13px;
min-height: 24px; // use min-height instead of height to allow the label to overflow a bit
margin-bottom: 6px;
position: relative; position: relative;
} }
@ -51,9 +52,8 @@ limitations under the License.
} }
.mx_TabbedView_maskedIcon { .mx_TabbedView_maskedIcon {
margin-left: 6px; margin-left: 8px;
margin-right: 9px; margin-right: 16px;
margin-top: 1px;
width: 16px; width: 16px;
height: 16px; height: 16px;
display: inline-block; display: inline-block;
@ -65,10 +65,9 @@ limitations under the License.
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: 16px; mask-size: 16px;
width: 16px; width: 16px;
height: 22px; height: 16px;
mask-position: center; mask-position: center;
content: ''; content: '';
vertical-align: middle;
} }
.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before {

View file

@ -48,7 +48,6 @@ limitations under the License.
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
margin: 0 auto; margin: 0 auto;
padding-left: 40px;
padding-right: 80px; padding-right: 80px;
} }

View file

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

View file

@ -36,7 +36,6 @@ limitations under the License.
} }
.mx_Dialog_title { .mx_Dialog_title {
text-align: center;
margin-bottom: 24px; margin-bottom: 24px;
} }
} }

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
.mx_SetPasswordDialog_change_password input { .mx_DesktopBuildsNotice {
border-radius: 3px; text-align: center;
border: 1px solid $input-border-color; padding: 0 16px;
padding: 9px;
color: $primary-fg-color;
background-color: $primary-bg-color;
font-size: $font-15px;
max-width: 280px;
margin-bottom: 10px;
}
.mx_SetPasswordDialog_change_password_button { > * {
margin-top: 68px; vertical-align: middle;
} }
.mx_SetPasswordDialog .mx_Dialog_content { > img {
margin-bottom: 0px; margin-right: 8px;
}
} }

View file

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

View file

@ -40,6 +40,7 @@ limitations under the License.
width: 20px; width: 20px;
margin: 12px; margin: 12px;
top: 0; top: 0;
border-radius: 10px;
&::before { &::before {
content: ""; content: "";
@ -55,7 +56,6 @@ limitations under the License.
} }
.mx_BaseCard_back { .mx_BaseCard_back {
border-radius: 4px;
left: 0; left: 0;
&::before { &::before {
@ -66,7 +66,6 @@ limitations under the License.
} }
.mx_BaseCard_close { .mx_BaseCard_close {
border-radius: 10px;
right: 0; right: 0;
&::before { &::before {

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
$MiniAppTileHeight: 114px; $MiniAppTileHeight: 200px;
.mx_AppsDrawer { .mx_AppsDrawer {
margin: 5px 5px 5px 18px; margin: 5px 5px 5px 18px;
@ -78,10 +78,6 @@ $MiniAppTileHeight: 114px;
font-size: $font-12px; font-size: $font-12px;
} }
.mx_AddWidget_button_full_width {
max-width: 960px;
}
.mx_SetAppURLDialog_input { .mx_SetAppURLDialog_input {
border-radius: 3px; border-radius: 3px;
border: 1px solid $input-border-color; border: 1px solid $input-border-color;
@ -92,7 +88,6 @@ $MiniAppTileHeight: 114px;
} }
.mx_AppTile { .mx_AppTile {
max-width: 960px;
width: 50%; width: 50%;
border: 5px solid $widget-menu-bar-bg-color; border: 5px solid $widget-menu-bar-bg-color;
border-radius: 4px; border-radius: 4px;
@ -105,7 +100,6 @@ $MiniAppTileHeight: 114px;
} }
.mx_AppTileFullWidth { .mx_AppTileFullWidth {
max-width: 960px;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -116,7 +110,6 @@ $MiniAppTileHeight: 114px;
} }
.mx_AppTile_mini { .mx_AppTile_mini {
max-width: 960px;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -220,9 +213,10 @@ $MiniAppTileHeight: 114px;
} }
.mx_AppTileBody_mini { .mx_AppTileBody_mini {
height: 112px; height: $MiniAppTileHeight;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border-radius: 8px;
} }
.mx_AppTile .mx_AppTileBody, .mx_AppTile .mx_AppTileBody,

View file

@ -96,11 +96,21 @@ limitations under the License.
} }
.mx_MemberList_invite span { .mx_MemberList_invite span {
background-image: url('$(res)/img/element-icons/room/invite.svg'); padding: 8px 0;
background-repeat: no-repeat; display: inline-flex;
background-position: center left;
background-size: 20px; &::before {
padding: 8px 0 8px 25px; 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 { .mx_MemberList_inviteCommunity span {

View file

@ -217,7 +217,7 @@ limitations under the License.
} }
} }
&.mx_MessageComposer_hangup::before { &.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before {
background-color: $warning-color; background-color: $warning-color;
} }
} }

View file

@ -68,3 +68,4 @@ limitations under the License.
cursor: pointer; cursor: pointer;
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { .mx_AvatarSetting_avatar {
width: $font-88px; width: 90px;
height: $font-88px; min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words
margin-left: 13px; height: 90px;
margin-top: 8px;
position: relative; 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; box-sizing: border-box;
} }
@ -30,7 +73,7 @@ limitations under the License.
} }
.mx_AccessibleButton.mx_AccessibleButton_kind_link_sm { .mx_AccessibleButton.mx_AccessibleButton_kind_link_sm {
color: $button-danger-bg-color; width: 100%;
} }
& > img { & > img {
@ -41,8 +84,9 @@ limitations under the License.
& > img, & > img,
.mx_AvatarSetting_avatarPlaceholder { .mx_AvatarSetting_avatarPlaceholder {
display: block; display: block;
height: $font-88px; height: 90px;
border-radius: 4px; border-radius: 90px;
cursor: pointer;
} }
.mx_AvatarSetting_avatarPlaceholder::before { .mx_AvatarSetting_avatarPlaceholder::before {
@ -58,6 +102,29 @@ limitations under the License.
left: 0; left: 0;
right: 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 { .mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder {

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { .mx_ProfileSettings_controls {
flex-grow: 1; 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 { .mx_ProfileSettings_controls .mx_Field #profileTopic {
@ -41,3 +48,17 @@ limitations under the License.
.mx_ProfileSettings_avatarUpload { .mx_ProfileSettings_avatarUpload {
display: none; 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 <p> above the buttons
margin-bottom: 28px;
> .mx_AccessibleButton_kind_link {
padding-left: 0; // to align with left side
}
}

View file

@ -22,6 +22,13 @@ limitations under the License.
margin-top: 0; 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_accountSection .mx_Spinner,
.mx_GeneralUserSettingsTab_discovery .mx_Spinner { .mx_GeneralUserSettingsTab_discovery .mx_Spinner {
// Move the spinner to the left side of the container (default center) // Move the spinner to the left side of the container (default center)

View file

@ -23,9 +23,16 @@ limitations under the License.
z-index: 100; z-index: 100;
box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); 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 { .mx_CallPreview {
pointer-events: initial; // restore pointer events so the user can leave/interact
cursor: pointer;
.mx_VideoView { .mx_VideoView {
width: 350px; width: 350px;
} }
@ -37,7 +44,7 @@ limitations under the License.
} }
.mx_AppTile_persistedWrapper div { .mx_AppTile_persistedWrapper div {
min-width: 300px; min-width: 350px;
} }
.mx_IncomingCallBox { .mx_IncomingCallBox {
@ -45,11 +52,14 @@ limitations under the License.
background-color: $primary-bg-color; background-color: $primary-bg-color;
padding: 8px; padding: 8px;
pointer-events: initial; // restore pointer events so the user can accept/decline
cursor: pointer;
.mx_IncomingCallBox_CallerInfo { .mx_IncomingCallBox_CallerInfo {
display: flex; display: flex;
direction: row; direction: row;
img { img, .mx_BaseAvatar_initial {
margin: 8px; margin: 8px;
} }

View file

@ -0,0 +1,157 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.1438 2.34375H7.85617C5.93938 2.34375 5.24431 2.54333 4.54356 2.91809C3.84281 3.29286 3.29286 3.84281 2.91809 4.54356C2.54333 5.24431 2.34375 5.93938 2.34375 7.85617V16.1438C2.34375 18.0606 2.54333 18.7557 2.91809 19.4564C3.29286 20.1572 3.84281 20.7071 4.54356 21.0819C5.24431 21.4567 5.93938 21.6562 7.85617 21.6562H16.1438C18.0606 21.6562 18.7557 21.4567 19.4564 21.0819C20.1572 20.7071 20.7071 20.1572 21.0819 19.4564C21.4567 18.7557 21.6562 18.0606 21.6562 16.1438V7.85617C21.6562 5.93938 21.4567 5.24431 21.0819 4.54356C20.7071 3.84281 20.1572 3.29286 19.4564 2.91809C18.7557 2.54333 18.0606 2.34375 16.1438 2.34375Z" fill="url(#paint0_linear)"/>
</g>
<g filter="url(#filter1_ddddi)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0969 6.01875C10.0969 5.56829 10.462 5.20312 10.9125 5.20312C13.9155 5.20312 16.35 7.63758 16.35 10.6406C16.35 11.0911 15.9848 11.4562 15.5344 11.4562C15.0839 11.4562 14.7187 11.0911 14.7187 10.6406C14.7187 8.53849 13.0146 6.83437 10.9125 6.83437C10.462 6.83437 10.0969 6.46921 10.0969 6.01875Z" fill="white"/>
</g>
<g filter="url(#filter2_ddddi)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9031 17.9813C13.9031 18.4317 13.538 18.7969 13.0875 18.7969C10.0845 18.7969 7.65001 16.3624 7.65001 13.3594C7.65001 12.9089 8.01518 12.5437 8.46564 12.5437C8.9161 12.5437 9.28126 12.9089 9.28126 13.3594C9.28126 15.4615 10.9854 17.1656 13.0875 17.1656C13.538 17.1656 13.9031 17.5308 13.9031 17.9813Z" fill="white"/>
</g>
<g filter="url(#filter3_ddddi)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.01875 13.9031C5.56829 13.9031 5.20312 13.538 5.20312 13.0875C5.20312 10.0845 7.63758 7.65001 10.6406 7.65001C11.0911 7.65001 11.4562 8.01518 11.4562 8.46564C11.4562 8.91609 11.0911 9.28126 10.6406 9.28126C8.53849 9.28126 6.83437 10.9854 6.83437 13.0875C6.83437 13.538 6.46921 13.9031 6.01875 13.9031Z" fill="white"/>
</g>
<g filter="url(#filter4_ddddi)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.9812 10.0969C18.4317 10.0969 18.7969 10.462 18.7969 10.9125C18.7969 13.9155 16.3624 16.35 13.3594 16.35C12.9089 16.35 12.5437 15.9848 12.5437 15.5344C12.5437 15.0839 12.9089 14.7187 13.3594 14.7187C15.4615 14.7187 17.1656 13.0146 17.1656 10.9125C17.1656 10.462 17.5308 10.0969 17.9812 10.0969Z" fill="white"/>
</g>
<defs>
<filter id="filter0_dd" x="1.54688" y="1.92188" width="20.9062" height="20.9062" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="0.375"/>
<feGaussianBlur stdDeviation="0.398438"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.09 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="0.234375"/>
<feGaussianBlur stdDeviation="0.28125"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/>
</filter>
<filter id="filter1_ddddi" x="6.95624" y="4.03125" width="12.5344" height="12.5344" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="0.328125"/>
<feGaussianBlur stdDeviation="0.515625"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.26562"/>
<feGaussianBlur stdDeviation="0.632812"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.96875"/>
<feGaussianBlur stdDeviation="1.57031"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect2_dropShadow" result="effect3_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="-0.1875" dy="0.421875"/>
<feGaussianBlur stdDeviation="0.339844"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect3_dropShadow" result="effect4_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.679688"/>
<feGaussianBlur stdDeviation="0.269531"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.678431 0 0 0 0 0.819608 0 0 0 0 0.726431 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect5_innerShadow"/>
</filter>
<filter id="filter2_ddddi" x="4.5094" y="11.3719" width="12.5344" height="12.5344" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="0.328125"/>
<feGaussianBlur stdDeviation="0.515625"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.26562"/>
<feGaussianBlur stdDeviation="0.632812"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.96875"/>
<feGaussianBlur stdDeviation="1.57031"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect2_dropShadow" result="effect3_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="-0.1875" dy="0.421875"/>
<feGaussianBlur stdDeviation="0.339844"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect3_dropShadow" result="effect4_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.679688"/>
<feGaussianBlur stdDeviation="0.269531"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.678431 0 0 0 0 0.819608 0 0 0 0 0.726431 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect5_innerShadow"/>
</filter>
<filter id="filter3_ddddi" x="2.0625" y="6.47815" width="12.5344" height="12.5344" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="0.328125"/>
<feGaussianBlur stdDeviation="0.515625"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.26562"/>
<feGaussianBlur stdDeviation="0.632812"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.96875"/>
<feGaussianBlur stdDeviation="1.57031"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect2_dropShadow" result="effect3_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="-0.1875" dy="0.421875"/>
<feGaussianBlur stdDeviation="0.339844"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect3_dropShadow" result="effect4_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.679688"/>
<feGaussianBlur stdDeviation="0.269531"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.678431 0 0 0 0 0.819608 0 0 0 0 0.726431 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect5_innerShadow"/>
</filter>
<filter id="filter4_ddddi" x="9.40314" y="8.92499" width="12.5344" height="12.5344" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="0.328125"/>
<feGaussianBlur stdDeviation="0.515625"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.26562"/>
<feGaussianBlur stdDeviation="0.632812"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.96875"/>
<feGaussianBlur stdDeviation="1.57031"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect2_dropShadow" result="effect3_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="-0.1875" dy="0.421875"/>
<feGaussianBlur stdDeviation="0.339844"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="overlay" in2="effect3_dropShadow" result="effect4_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.679688"/>
<feGaussianBlur stdDeviation="0.269531"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.678431 0 0 0 0 0.819608 0 0 0 0 0.726431 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect5_innerShadow"/>
</filter>
<linearGradient id="paint0_linear" x1="12" y1="2.34375" x2="12" y2="21.6562" gradientUnits="userSpaceOnUse">
<stop stop-color="#1ED9A3"/>
<stop offset="1" stop-color="#0DBD8B"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,3 +1,3 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M28 8.5C28 12.0899 25.0899 15 21.5 15C17.9101 15 15 12.0899 15 8.5C15 4.91015 17.9101 2 21.5 2C25.0899 2 28 4.91015 28 8.5ZM22.5 5C22.5 4.44772 22.0523 4 21.5 4C20.9477 4 20.5 4.44772 20.5 5V7.6286H17.9C17.4029 7.6286 17 8.02089 17 8.5048C17 8.98871 17.4029 9.381 17.9 9.381H20.5V12.0096C20.5 12.5619 20.9477 13.0096 21.5 13.0096C22.0523 13.0096 22.5 12.5619 22.5 12.0096V9.381H25.1C25.5971 9.381 26 8.98871 26 8.5048C26 8.02089 25.5971 7.6286 25.1 7.6286H22.5V5ZM21.5 16C23.6351 16 25.5619 15.1078 26.9278 13.6759C26.9755 14.1107 27 14.5525 27 15C27 18.9261 25.1146 22.4117 22.1998 24.601V24.6009C20.348 25.9918 18.0808 26.8595 15.6175 26.9844C15.413 26.9948 15.2071 27 15 27C8.37258 27 3 21.6274 3 15C3 8.37258 8.37258 3 15 3C15.4475 3 15.8893 3.0245 16.3241 3.07223C14.929 4.40304 14.0462 6.26631 14.0018 8.336C12.8183 8.89737 12 10.1031 12 11.5C12 13.433 13.567 15 15.5 15C16.0892 15 16.6445 14.8544 17.1316 14.5972C18.3618 15.4802 19.8702 16 21.5 16ZM14.9998 24.6C17.5942 24.6 19.9482 23.5709 21.6759 21.8986C20.6074 19.2607 18.0209 17.4 14.9998 17.4C11.9787 17.4 9.39221 19.2607 8.32376 21.8986C10.0514 23.5709 12.4054 24.6 14.9998 24.6Z" fill="white"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M19.1001 9C18.7779 9 18.5168 8.73883 18.5168 8.41667V6.08333H16.1834C15.8613 6.08333 15.6001 5.82217 15.6001 5.5C15.6001 5.17783 15.8613 4.91667 16.1834 4.91667H18.5168V2.58333C18.5168 2.26117 18.7779 2 19.1001 2C19.4223 2 19.6834 2.26117 19.6834 2.58333V4.91667H22.0168C22.3389 4.91667 22.6001 5.17783 22.6001 5.5C22.6001 5.82217 22.3389 6.08333 22.0168 6.08333H19.6834V8.41667C19.6834 8.73883 19.4223 9 19.1001 9ZM19.6001 11C20.0669 11 20.5212 10.9467 20.9574 10.8458C21.1161 11.5383 21.2 12.2594 21.2 13C21.2 16.1409 19.6917 18.9294 17.3598 20.6808V20.6807C16.0014 21.7011 14.3635 22.3695 12.5815 22.5505C12.2588 22.5832 11.9314 22.6 11.6 22.6C6.29807 22.6 2 18.302 2 13C2 7.69809 6.29807 3.40002 11.6 3.40002C12.3407 3.40002 13.0618 3.48391 13.7543 3.64268C13.6534 4.07884 13.6001 4.53319 13.6001 5C13.6001 8.31371 16.2864 11 19.6001 11ZM11.5999 20.68C13.6754 20.68 15.5585 19.8567 16.9407 18.5189C16.0859 16.4086 14.0167 14.92 11.5998 14.92C9.18298 14.92 7.11378 16.4086 6.25901 18.5189C7.64115 19.8567 9.52436 20.68 11.5999 20.68ZM11.7426 7.41172C10.3168 7.54168 9.2 8.74043 9.2 10.2C9.2 11.7464 10.4536 13 12 13C13.0308 13 13.9315 12.443 14.4176 11.6135C13.0673 10.6058 12.0929 9.12248 11.7426 7.41172Z" fill="black"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -87,11 +87,10 @@ $dialog-background-bg-color: $header-panel-bg-color;
$lightbox-background-bg-color: #000; $lightbox-background-bg-color: #000;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #e7e7e7; $settings-profile-placeholder-bg-color: #21262c;
$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-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; $settings-subsection-fg-color: $text-secondary-color;
$topleftmenu-color: $text-primary-color; $topleftmenu-color: $text-primary-color;

View file

@ -86,10 +86,9 @@ $lightbox-background-bg-color: #000;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #e7e7e7; $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-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; $settings-subsection-fg-color: $text-secondary-color;
$topleftmenu-color: $text-primary-color; $topleftmenu-color: $text-primary-color;

View file

@ -144,10 +144,9 @@ $blockquote-fg-color: #777;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #e7e7e7; $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-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; $settings-subsection-fg-color: #61708b;
$voip-decline-color: #f48080; $voip-decline-color: #f48080;

View file

@ -124,15 +124,15 @@ $pinned-unread-color: var(--warning-color);
$warning-color: var(--warning-color); $warning-color: var(--warning-color);
$button-danger-disabled-bg-color: var(--warning-color-50pct); // still needs alpha at 0.5 $button-danger-disabled-bg-color: var(--warning-color-50pct); // still needs alpha at 0.5
// //
// --username colors // --username colors (which use a 0-based index)
$username-variant1-color: var(--username-colors_1, $username-variant1-color); $username-variant1-color: var(--username-colors_0, $username-variant1-color);
$username-variant2-color: var(--username-colors_2, $username-variant2-color); $username-variant2-color: var(--username-colors_1, $username-variant2-color);
$username-variant3-color: var(--username-colors_3, $username-variant3-color); $username-variant3-color: var(--username-colors_2, $username-variant3-color);
$username-variant4-color: var(--username-colors_4, $username-variant4-color); $username-variant4-color: var(--username-colors_3, $username-variant4-color);
$username-variant5-color: var(--username-colors_5, $username-variant5-color); $username-variant5-color: var(--username-colors_4, $username-variant5-color);
$username-variant6-color: var(--username-colors_6, $username-variant6-color); $username-variant6-color: var(--username-colors_5, $username-variant6-color);
$username-variant7-color: var(--username-colors_7, $username-variant7-color); $username-variant7-color: var(--username-colors_6, $username-variant7-color);
$username-variant8-color: var(--username-colors_8, $username-variant8-color); $username-variant8-color: var(--username-colors_7, $username-variant8-color);
// //
// --timeline-highlights-color // --timeline-highlights-color
$event-selected-color: var(--timeline-highlights-color); $event-selected-color: var(--timeline-highlights-color);

View file

@ -137,11 +137,10 @@ $blockquote-bar-color: #ddd;
$blockquote-fg-color: #777; $blockquote-fg-color: #777;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #e7e7e7; $settings-profile-placeholder-bg-color: #f4f6fa;
$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-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; $settings-subsection-fg-color: #61708b;
$voip-decline-color: #f48080; $voip-decline-color: #f48080;

View file

@ -30,6 +30,7 @@ import {Notifier} from "../Notifier";
import type {Renderer} from "react-dom"; import type {Renderer} from "react-dom";
import RightPanelStore from "../stores/RightPanelStore"; import RightPanelStore from "../stores/RightPanelStore";
import WidgetStore from "../stores/WidgetStore"; import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler";
declare global { declare global {
interface Window { interface Window {
@ -53,6 +54,7 @@ declare global {
mxNotifier: typeof Notifier; mxNotifier: typeof Notifier;
mxRightPanelStore: RightPanelStore; mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore; mxWidgetStore: WidgetStore;
mxCallHandler: CallHandler;
} }
interface Document { interface Document {
@ -62,6 +64,9 @@ declare global {
interface Navigator { interface Navigator {
userLanguage?: string; userLanguage?: string;
// https://github.com/Microsoft/TypeScript/issues/19473
// https://developer.mozilla.org/en-US/docs/Web/API/MediaSession
mediaSession: any;
} }
interface StorageEstimate { interface StorageEstimate {

View file

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

View file

@ -82,6 +82,7 @@ function urlForColor(color) {
const colorToDataURLCache = new Map(); const colorToDataURLCache = new Map();
export function defaultAvatarUrlForString(s) { export function defaultAvatarUrlForString(s) {
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8']; const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
let total = 0; let total = 0;
for (let i = 0; i < s.length; ++i) { for (let i = 0; i < s.length; ++i) {

View file

@ -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: <room ID of the call>
* }
*
* 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: <room that the place call button was pressed in>
* }
*
* {
* action: 'incoming_call'
* call: MatrixCall
* }
*
* {
* action: 'hangup'
* room_id: <room that the hangup button was pressed in>
* }
*
* {
* action: 'answer'
* room_id: <room that the answer button was pressed in>
* }
*/
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 => <code>{sub}</code>;
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
title: _t("Call failed due to misconfigured server"),
description: <div>
<p>{_t(
"Please ask the administrator of your homeserver " +
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
"order for calls to work reliably.",
{ homeserverDomain: cli.getDomain() }, { code },
)}</p>
<p>{_t(
"Alternatively, you can try to use the public server at " +
"<code>turn.matrix.org</code>, 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 },
)}</p>
</div>,
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;

513
src/CallHandler.tsx Normal file
View file

@ -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: <room ID of the call>
* }
*
* 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: <room that the place call button was pressed in>
* }
*
* {
* action: 'incoming_call'
* call: MatrixCall
* }
*
* {
* action: 'hangup'
* room_id: <room that the hangup button was pressed in>
* }
*
* {
* action: 'answer'
* room_id: <room that the answer button was pressed in>
* }
*/
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<string, Call>();
private audioPromises = new Map<string, Promise<void>>();
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 => <code>{sub}</code>;
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
title: _t("Call failed due to misconfigured server"),
description: <div>
<p>{_t(
"Please ask the administrator of your homeserver " +
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
"order for calls to work reliably.",
{ homeserverDomain: cli.getDomain() }, { code },
)}</p>
<p>{_t(
"Alternatively, you can try to use the public server at " +
"<code>turn.matrix.org</code>, 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 },
)}</p>
</div>,
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, {});
});
}
}

View file

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

View file

@ -19,6 +19,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import * as linkify from 'linkifyjs'; import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix'; import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element'; 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 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 * 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 // add blank targets to all hyperlinks except vector URLs
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) { 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) { if (attribs.href) {
@ -227,7 +228,7 @@ const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to mat
}, },
}; };
const sanitizeHtmlParams: sanitizeHtml.IOptions = { const sanitizeHtmlParams: IExtendedSanitizeOptions = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
'del', // for markdown 'del', // for markdown
@ -249,13 +250,14 @@ const sanitizeHtmlParams: sanitizeHtml.IOptions = {
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit // URL schemes we permit
allowedSchemes: PERMITTED_URL_SCHEMES, allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false, allowProtocolRelative: false,
transformTags, transformTags,
// 50 levels deep "should be enough for anyone"
nestingLimit: 50,
}; };
// this is the same as the above except with less rewriting // this is the same as the above except with less rewriting
const composerSanitizeHtmlParams: sanitizeHtml.IOptions = { const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
...sanitizeHtmlParams, ...sanitizeHtmlParams,
transformTags: { transformTags: {
'code': transformTags['code'], 'code': transformTags['code'],

View file

@ -17,9 +17,12 @@ See the License for the specific language governing permissions and
limitations under the License. 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 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 EventIndexPeg from './indexing/EventIndexPeg';
import createMatrixClient from './utils/createMatrixClient'; import createMatrixClient from './utils/createMatrixClient';
import Analytics from './Analytics'; import Analytics from './Analytics';
@ -47,44 +50,46 @@ import ThreepidInviteStore from "./stores/ThreepidInviteStore";
const HOMESERVER_URL_KEY = "mx_hs_url"; const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url"; const ID_SERVER_URL_KEY = "mx_is_url";
interface ILoadSessionOpts {
enableGuest?: boolean;
guestHsUrl?: string;
guestIsUrl?: string;
ignoreGuest?: boolean;
defaultDeviceDisplayName?: string;
fragmentQueryParams?: Record<string, string>;
}
/** /**
* Called at startup, to attempt to build a logged-in Matrix session. It tries * Called at startup, to attempt to build a logged-in Matrix session. It tries
* a number of things: * a number of things:
* *
*
* 1. if we have a guest access token in the fragment query params, it uses * 1. if we have a guest access token in the fragment query params, it uses
* that. * that.
*
* 2. if an access token is stored in local storage (from a previous session), * 2. if an access token is stored in local storage (from a previous session),
* it uses that. * it uses that.
*
* 3. it attempts to auto-register as a guest user. * 3. it attempts to auto-register as a guest user.
* *
* If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in * 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. * turn will raise on_logged_in and will_start_client events.
* *
* @param {object} opts * @param {object} [opts]
* * @param {object} [opts.fragmentQueryParams]: string->string map of the
* @param {object} opts.fragmentQueryParams: string->string map of the
* query-parameters extracted from the #-fragment of the starting URI. * query-parameters extracted from the #-fragment of the starting URI.
* * @param {boolean} [opts.enableGuest]: set to true to enable guest access
* @param {boolean} opts.enableGuest: set to true to enable guest access tokens * tokens and auto-guest registrations.
* and auto-guest registrations. * @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest
* * is true; defines the HS to register against.
* @params {string} opts.guestHsUrl: homeserver URL. Only used if enableGuest is * @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest
* true; defines the HS to register against. * is true; defines the IS to use.
* * @param {bool} [opts.ignoreGuest]: If the stored session is a guest account,
* @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is * ignore it and don't load it.
* true; defines the IS to use. * @param {string} [opts.defaultDeviceDisplayName]: Default display name to use
* * when registering as a guest.
* @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore
* it and don't load it.
*
* @returns {Promise} a promise which resolves when the above process completes. * @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 * Resolves to `true` if we ended up starting a session, or `false` if we
* failed. * failed.
*/ */
export async function loadSession(opts) { export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean> {
try { try {
let enableGuest = opts.enableGuest || false; let enableGuest = opts.enableGuest || false;
const guestHsUrl = opts.guestHsUrl; const guestHsUrl = opts.guestHsUrl;
@ -97,12 +102,13 @@ export async function loadSession(opts) {
enableGuest = false; enableGuest = false;
} }
if (enableGuest && if (
enableGuest &&
fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_user_id &&
fragmentQueryParams.guest_access_token fragmentQueryParams.guest_access_token
) { ) {
console.log("Using guest access credentials"); console.log("Using guest access credentials");
return _doSetLoggedIn({ return doSetLoggedIn({
userId: fragmentQueryParams.guest_user_id, userId: fragmentQueryParams.guest_user_id,
accessToken: fragmentQueryParams.guest_access_token, accessToken: fragmentQueryParams.guest_access_token,
homeserverUrl: guestHsUrl, homeserverUrl: guestHsUrl,
@ -110,7 +116,7 @@ export async function loadSession(opts) {
guest: true, guest: true,
}, true).then(() => true); }, true).then(() => true);
} }
const success = await _restoreFromLocalStorage({ const success = await restoreFromLocalStorage({
ignoreGuest: Boolean(opts.ignoreGuest), ignoreGuest: Boolean(opts.ignoreGuest),
}); });
if (success) { if (success) {
@ -118,7 +124,7 @@ export async function loadSession(opts) {
} }
if (enableGuest) { if (enableGuest) {
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
} }
// fall back to welcome screen // 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. // need to show the general failure dialog. Instead, just go back to welcome.
return false; 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. * is associated with them. The session is not loaded.
* @returns {String} The persisted session's owner, if an owner exists. Null otherwise. * @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(); const {hsUrl, userId, accessToken} = getLocalStorageSessionVars();
return hsUrl && userId && accessToken ? userId : null; 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 * @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. * for a real user. If there is no stored session, return null.
*/ */
export function getStoredSessionIsGuest() { export function getStoredSessionIsGuest(): boolean {
const sessVars = getLocalStorageSessionVars(); const sessVars = getLocalStorageSessionVars();
return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null; 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 * @returns {Promise} promise which resolves to true if we completed the token
* login, else false * login, else false
*/ */
export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { export function attemptTokenLogin(
queryParams: Record<string, string>,
defaultDeviceDisplayName?: string,
): Promise<boolean> {
if (!queryParams.loginToken) { if (!queryParams.loginToken) {
return Promise.resolve(false); return Promise.resolve(false);
} }
@ -184,8 +193,10 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
}, },
).then(function(creds) { ).then(function(creds) {
console.log("Logged in with token"); console.log("Logged in with token");
return _clearStorage().then(() => { return clearStorage().then(() => {
_persistCredentialsToLocalStorage(creds); persistCredentialsToLocalStorage(creds);
// remember that we just logged in
sessionStorage.setItem("mx_fresh_login", String(true));
return true; return true;
}); });
}).catch((err) => { }).catch((err) => {
@ -195,8 +206,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
}); });
} }
export function handleInvalidStoreError(e) { export function handleInvalidStoreError(e: InvalidStoreError): Promise<void> {
if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) { if (e.reason === InvalidStoreError.TOGGLED_LAZY_LOADING) {
return Promise.resolve().then(() => { return Promise.resolve().then(() => {
const lazyLoadEnabled = e.value; const lazyLoadEnabled = e.value;
if (lazyLoadEnabled) { if (lazyLoadEnabled) {
@ -229,7 +240,11 @@ export function handleInvalidStoreError(e) {
} }
} }
function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { function registerAsGuest(
hsUrl: string,
isUrl: string,
defaultDeviceDisplayName: string,
): Promise<boolean> {
console.log(`Doing guest login on ${hsUrl}`); console.log(`Doing guest login on ${hsUrl}`);
// create a temporary MatrixClient to do the login // create a temporary MatrixClient to do the login
@ -243,7 +258,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
}, },
}).then((creds) => { }).then((creds) => {
console.log(`Registered as guest: ${creds.user_id}`); console.log(`Registered as guest: ${creds.user_id}`);
return _doSetLoggedIn({ return doSetLoggedIn({
userId: creds.user_id, userId: creds.user_id,
deviceId: creds.device_id, deviceId: creds.device_id,
accessToken: creds.access_token, 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 * Retrieves information about the stored session in localstorage. The session
* may not be valid, as it is not tested for consistency here. * may not be valid, as it is not tested for consistency here.
* @returns {Object} Information about the session - see implementation for variables. * @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 hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
const accessToken = localStorage.getItem("mx_access_token"); 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 // The plan is to gradually move the localStorage access done here into
// SessionStore to avoid bugs where the view becomes out-of-sync with // SessionStore to avoid bugs where the view becomes out-of-sync with
// localStorage (e.g. isGuest etc.) // localStorage (e.g. isGuest etc.)
async function _restoreFromLocalStorage(opts) { async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise<boolean> {
const ignoreGuest = opts.ignoreGuest; const ignoreGuest = opts?.ignoreGuest;
if (!localStorage) { if (!localStorage) {
return false; return false;
@ -312,8 +336,11 @@ async function _restoreFromLocalStorage(opts) {
console.log("No pickle key available"); 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}`); console.log(`Restoring session for ${userId}`);
await _doSetLoggedIn({ await doSetLoggedIn({
userId: userId, userId: userId,
deviceId: deviceId, deviceId: deviceId,
accessToken: accessToken, accessToken: accessToken,
@ -321,6 +348,7 @@ async function _restoreFromLocalStorage(opts) {
identityServerUrl: isUrl, identityServerUrl: isUrl,
guest: isGuest, guest: isGuest,
pickleKey: pickleKey, pickleKey: pickleKey,
freshLogin: freshLogin,
}, false); }, false);
return true; return true;
} else { } else {
@ -329,7 +357,7 @@ async function _restoreFromLocalStorage(opts) {
} }
} }
async function _handleLoadSessionFailure(e) { async function handleLoadSessionFailure(e: Error): Promise<boolean> {
console.error("Unable to load session", e); console.error("Unable to load session", e);
const SessionRestoreErrorDialog = const SessionRestoreErrorDialog =
@ -342,7 +370,7 @@ async function _handleLoadSessionFailure(e) {
const [success] = await modal.finished; const [success] = await modal.finished;
if (success) { if (success) {
// user clicked continue. // user clicked continue.
await _clearStorage(); await clearStorage();
return false; return false;
} }
@ -363,7 +391,8 @@ async function _handleLoadSessionFailure(e) {
* *
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started * @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<MatrixClient> {
credentials.freshLogin = true;
stopMatrixClient(); stopMatrixClient();
const pickleKey = credentials.userId && credentials.deviceId const pickleKey = credentials.userId && credentials.deviceId
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
@ -375,7 +404,7 @@ export async function setLoggedIn(credentials) {
console.log("Pickle key not created"); 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 * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/ */
export function hydrateSession(credentials) { export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
const oldUserId = MatrixClientPeg.get().getUserId(); const oldUserId = MatrixClientPeg.get().getUserId();
const oldDeviceId = MatrixClientPeg.get().getDeviceId(); 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"); 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 * @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<MatrixClient> {
credentials.guest = Boolean(credentials.guest); credentials.guest = Boolean(credentials.guest);
const softLogout = isSoftLogout(); const softLogout = isSoftLogout();
@ -429,6 +461,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
" guest: " + credentials.guest + " guest: " + credentials.guest +
" hs: " + credentials.homeserverUrl + " hs: " + credentials.homeserverUrl +
" softLogout: " + softLogout, " softLogout: " + softLogout,
" freshLogin: " + credentials.freshLogin,
); );
// This is dispatched to indicate that the user is still in the process of logging in // 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 uses `setTimeout`, which does not guarantee ordering.)
dis.dispatch({action: 'on_logging_in'}, true); dis.dispatch({action: 'on_logging_in'}, true);
if (clearStorage) { if (clearStorageEnabled) {
await _clearStorage(); await clearStorage();
} }
const results = await StorageManager.checkConsistency(); 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. // crypto store, we'll be generally confused when handling encrypted data.
// Show a modal recommending a full reset of storage. // Show a modal recommending a full reset of storage.
if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
const signOut = await _showStorageEvictedDialog(); const signOut = await showStorageEvictedDialog();
if (signOut) { if (signOut) {
await _clearStorage(); await clearStorage();
// This error feels a bit clunky, but we want to make sure we don't go any // 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. // further and instead head back to sign in.
throw new AbortLoginAndRebuildStorage( throw new AbortLoginAndRebuildStorage(
@ -462,19 +495,26 @@ async function _doSetLoggedIn(credentials, clearStorage) {
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); 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) { if (localStorage) {
try { try {
_persistCredentialsToLocalStorage(credentials); persistCredentialsToLocalStorage(credentials);
// make sure we don't think that it's a fresh login any more
// The user registered as a PWLU (PassWord-Less User), the generated password sessionStorage.removeItem("mx_fresh_login");
// 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,
});
}
} catch (e) { } catch (e) {
console.warn("Error using local storage: can't persist session!", 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!"); console.warn("No local storage available: can't persist session!");
} }
MatrixClientPeg.replaceUsingCreds(credentials);
dis.dispatch({ action: 'on_logged_in' }); dis.dispatch({ action: 'on_logged_in' });
await startMatrixClient(/*startSyncing=*/!softLogout); await startMatrixClient(/*startSyncing=*/!softLogout);
return MatrixClientPeg.get(); return client;
} }
function _showStorageEvictedDialog() { function showStorageEvictedDialog(): Promise<boolean> {
const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog');
return new Promise(resolve => { return new Promise(resolve => {
Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, {
@ -503,7 +541,7 @@ function _showStorageEvictedDialog() {
// `instanceof`. Babel 7 supports this natively in their class handling. // `instanceof`. Babel 7 supports this natively in their class handling.
class AbortLoginAndRebuildStorage extends Error { } class AbortLoginAndRebuildStorage extends Error { }
function _persistCredentialsToLocalStorage(credentials) { function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void {
localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl);
if (credentials.identityServerUrl) { if (credentials.identityServerUrl) {
localStorage.setItem(ID_SERVER_URL_KEY, 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)); localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
if (credentials.pickleKey) { if (credentials.pickleKey) {
localStorage.setItem("mx_has_pickle_key", true); localStorage.setItem("mx_has_pickle_key", String(true));
} else { } else {
if (localStorage.getItem("mx_has_pickle_key")) { if (localStorage.getItem("mx_has_pickle_key")) {
console.error("Expected a pickle key, but none provided. Encryption may not work."); 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 * 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()) return;
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
@ -566,7 +604,7 @@ export function logout() {
); );
} }
export function softLogout() { export function softLogout(): void {
if (!MatrixClientPeg.get()) return; if (!MatrixClientPeg.get()) return;
// Track that we've detected and trapped a soft logout. This helps prevent other // 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. // 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"; return localStorage.getItem("mx_soft_logout") === "true";
} }
export function isLoggingOut() { export function isLoggingOut(): boolean {
return _isLoggingOut; return _isLoggingOut;
} }
@ -601,7 +639,7 @@ export function isLoggingOut() {
* @param {boolean} startSyncing True (default) to actually start * @param {boolean} startSyncing True (default) to actually start
* syncing the client. * syncing the client.
*/ */
async function startMatrixClient(startSyncing=true) { async function startMatrixClient(startSyncing = true): Promise<void> {
console.log(`Lifecycle: Starting MatrixClient`); console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used // 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 * Stops a running client and all related services, and clears persistent
* storage. Used after a session has been logged out. * storage. Used after a session has been logged out.
*/ */
export async function onLoggedOut() { export async function onLoggedOut(): Promise<void> {
_isLoggingOut = false; _isLoggingOut = false;
// Ensure that we dispatch a view change **before** stopping the client so // Ensure that we dispatch a view change **before** stopping the client so
// so that React components unmount first. This avoids React soft crashes // so that React components unmount first. This avoids React soft crashes
// that can occur when components try to use a null client. // that can occur when components try to use a null client.
dis.dispatch({action: 'on_logged_out'}, true); dis.dispatch({action: 'on_logged_out'}, true);
stopMatrixClient(); stopMatrixClient();
await _clearStorage({deleteEverything: true}); await clearStorage({deleteEverything: true});
} }
/** /**
* @param {object} opts Options for how to clear storage. * @param {object} opts Options for how to clear storage.
* @returns {Promise} promise which resolves once the stores have been cleared * @returns {Promise} promise which resolves once the stores have been cleared
*/ */
async function _clearStorage(opts: {deleteEverything: boolean}) { async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void> {
Analytics.disable(); Analytics.disable();
if (window.localStorage) { if (window.localStorage) {
@ -712,7 +750,7 @@ async function _clearStorage(opts: {deleteEverything: boolean}) {
* @param {boolean} unsetClient True (default) to abandon the client * @param {boolean} unsetClient True (default) to abandon the client
* on MatrixClientPeg after stopping. * on MatrixClientPeg after stopping.
*/ */
export function stopMatrixClient(unsetClient=true) { export function stopMatrixClient(unsetClient = true): void {
Notifier.stop(); Notifier.stop();
UserActivity.sharedInstance().stop(); UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset(); TypingStore.sharedInstance().reset();

View file

@ -18,35 +18,72 @@ See the License for the specific language governing permissions and
limitations under the License. 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 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 { export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) { private hsUrl: string;
this._hsUrl = hsUrl; private isUrl: string;
this._isUrl = isUrl; private fallbackHsUrl: string;
this._fallbackHsUrl = fallbackHsUrl; private currentFlowIndex: number;
this._currentFlowIndex = 0; // TODO: Flows need a type in JS SDK
this._flows = []; private flows: Array<ILoginFlow>;
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; private defaultDeviceDisplayName: string;
this._tempClient = null; // memoize 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() { public getHomeserverUrl(): string {
return this._hsUrl; return this.hsUrl;
} }
getIdentityServerUrl() { public getIdentityServerUrl(): string {
return this._isUrl; return this.isUrl;
} }
setHomeserverUrl(hsUrl) { public setHomeserverUrl(hsUrl: string): void {
this._tempClient = null; // clear memoization this.tempClient = null; // clear memoization
this._hsUrl = hsUrl; this.hsUrl = hsUrl;
} }
setIdentityServerUrl(isUrl) { public setIdentityServerUrl(isUrl: string): void {
this._tempClient = null; // clear memoization this.tempClient = null; // clear memoization
this._isUrl = isUrl; this.isUrl = isUrl;
} }
/** /**
@ -54,40 +91,41 @@ export default class Login {
* requests. * requests.
* @returns {MatrixClient} * @returns {MatrixClient}
*/ */
createTemporaryClient() { public createTemporaryClient(): MatrixClient {
if (this._tempClient) return this._tempClient; // use memoization if (this.tempClient) return this.tempClient; // use memoization
return this._tempClient = Matrix.createClient({ return this.tempClient = Matrix.createClient({
baseUrl: this._hsUrl, baseUrl: this.hsUrl,
idBaseUrl: this._isUrl, idBaseUrl: this.isUrl,
}); });
} }
getFlows() { public async getFlows(): Promise<Array<ILoginFlow>> {
const self = this;
const client = this.createTemporaryClient(); const client = this.createTemporaryClient();
return client.loginFlows().then(function(result) { const { flows } = await client.loginFlows();
self._flows = result.flows; this.flows = flows;
self._currentFlowIndex = 0; this.currentFlowIndex = 0;
// technically the UI should display options for all flows for the // technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here. // user to then choose one, so return all the flows here.
return self._flows; return this.flows;
});
} }
chooseFlow(flowIndex) { public chooseFlow(flowIndex): void {
this._currentFlowIndex = flowIndex; this.currentFlowIndex = flowIndex;
} }
getCurrentFlowStep() { public getCurrentFlowStep(): string {
// technically the flow can have multiple steps, but no one does this // technically the flow can have multiple steps, but no one does this
// for login so we can ignore it. // for login so we can ignore it.
const flowStep = this._flows[this._currentFlowIndex]; const flowStep = this.flows[this.currentFlowIndex];
return flowStep ? flowStep.type : null; return flowStep ? flowStep.type : null;
} }
loginViaPassword(username, phoneCountry, phoneNumber, pass) { public loginViaPassword(
const self = this; username: string,
phoneCountry: string,
phoneNumber: string,
password: string,
): Promise<IMatrixClientCreds> {
const isEmail = username.indexOf("@") > 0; const isEmail = username.indexOf("@") > 0;
let identifier; let identifier;
@ -113,14 +151,14 @@ export default class Login {
} }
const loginParams = { const loginParams = {
password: pass, password,
identifier: identifier, identifier,
initial_device_display_name: this._defaultDeviceDisplayName, initial_device_display_name: this.defaultDeviceDisplayName,
}; };
const tryFallbackHs = (originalError) => { const tryFallbackHs = (originalError) => {
return sendLoginRequest( return sendLoginRequest(
self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams, this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((fallbackError) => { ).catch((fallbackError) => {
console.log("fallback HS login failed", fallbackError); console.log("fallback HS login failed", fallbackError);
// throw the original error // throw the original error
@ -130,11 +168,11 @@ export default class Login {
let originalLoginError = null; let originalLoginError = null;
return sendLoginRequest( return sendLoginRequest(
self._hsUrl, self._isUrl, 'm.login.password', loginParams, this.hsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((error) => { ).catch((error) => {
originalLoginError = error; originalLoginError = error;
if (error.httpStatus === 403) { if (error.httpStatus === 403) {
if (self._fallbackHsUrl) { if (this.fallbackHsUrl) {
return tryFallbackHs(originalLoginError); 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} hsUrl the base url of the Homeserver used to log in.
* @param {string} isUrl the base url of the default identity server * @param {string} isUrl the base url of the default identity server
* @param {string} loginType the type of login to do * @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} * @returns {MatrixClientCreds}
*/ */
export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { export async function sendLoginRequest(
hsUrl: string,
isUrl: string,
loginType: string,
loginParams: ILoginParams,
): Promise<IMatrixClientCreds> {
const client = Matrix.createClient({ const client = Matrix.createClient({
baseUrl: hsUrl, baseUrl: hsUrl,
idBaseUrl: isUrl, idBaseUrl: isUrl,

View file

@ -31,17 +31,18 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
import * as StorageManager from './utils/StorageManager'; import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient'; 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"; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
export interface IMatrixClientCreds { export interface IMatrixClientCreds {
homeserverUrl: string; homeserverUrl: string;
identityServerUrl: string; identityServerUrl: string;
userId: string; userId: string;
deviceId: string; deviceId?: string;
accessToken: string; accessToken: string;
guest: boolean; guest?: boolean;
pickleKey?: string; pickleKey?: string;
freshLogin?: boolean;
} }
// TODO: Move this to the js-sdk // TODO: Move this to the js-sdk
@ -192,6 +193,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
this.matrixClient.setCryptoTrustCrossSignedDevices( this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'), !SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
); );
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
StorageManager.setCryptoInitialised(true); StorageManager.setCryptoInitialised(true);
} }
} catch (e) { } catch (e) {

View file

@ -24,7 +24,6 @@ import dis from './dispatcher/dispatcher';
import * as sdk from './index'; import * as sdk from './index';
import Modal from './Modal'; import Modal from './Modal';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
// import {MatrixClientPeg} from './MatrixClientPeg';
// Regex for what a "safe" or "Matrix-looking" localpart would be. // 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 // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -44,29 +43,6 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
*/ */
export async function startAnyRegistrationFlow(options) { export async function startAnyRegistrationFlow(options) {
if (options === undefined) 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 QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true, hasCancelButton: true,
@ -90,24 +66,4 @@ export async function startAnyRegistrationFlow(options) {
} }
}, },
}); });
//}
} }
// 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!");
// }

View file

@ -26,58 +26,6 @@ export function getDisplayAliasForRoom(room) {
return room.getCanonicalAlias() || room.getAltAliases()[0]; 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) { export function looksLikeDirectMessageRoom(room, myUserId) {
const myMembership = room.getMyMembership(); const myMembership = room.getMyMembership();
const me = room.getMember(myUserId); const me = room.getMember(myUserId);

View file

@ -33,6 +33,11 @@ export const DEFAULTS: ConfigOptions = {
// Default conference domain // Default conference domain
preferredDomain: "jitsi.riot.im", 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 { export default class SdkConfig {

View file

@ -24,6 +24,7 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { isSecureBackupRequired } from './utils/WellKnownUtils';
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; 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 // 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 // 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 // single secret storage operation, as it will clear the cached keys once the
// operation ends. // operation ends.
let secretStorageKeys = {}; let secretStorageKeys = {};
let secretStorageKeyInfo = {};
let secretStorageBeingAccessed = false; let secretStorageBeingAccessed = false;
let nonInteractive = false;
let dehydrationCache = {};
function isCachingAllowed() { function isCachingAllowed() {
return secretStorageBeingAccessed; return secretStorageBeingAccessed;
} }
@ -66,6 +72,20 @@ async function confirmToDismiss() {
return !sure; 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) { async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const keyInfoEntries = Object.entries(keyInfos); const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) { if (keyInfoEntries.length > 1) {
@ -78,17 +98,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
return [keyId, secretStorageKeys[keyId]]; return [keyId, secretStorageKeys[keyId]];
} }
const inputToKey = async ({ passphrase, recoveryKey }) => { if (dehydrationCache.key) {
if (passphrase) { if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
return deriveKey( cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo);
passphrase, return [keyId, dehydrationCache.key];
keyInfo.passphrase.salt,
keyInfo.passphrase.iterations,
);
} else {
return decodeRecoveryKey(recoveryKey);
} }
}; }
if (nonInteractive) {
throw new Error("Could not unlock non-interactively");
}
const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
AccessSecretStorageDialog, AccessSecretStorageDialog,
/* props= */ /* props= */
@ -118,14 +139,56 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const key = await inputToKey(input); const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session // Save to cache to avoid future prompts in the current session
cacheSecretStorageKey(keyId, key); cacheSecretStorageKey(keyId, key, keyInfo);
return [keyId, key]; 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()) { if (isCachingAllowed()) {
secretStorageKeys[keyId] = key; secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
} }
} }
@ -176,6 +239,7 @@ export const crossSigningCallbacks = {
getSecretStorageKey, getSecretStorageKey,
cacheSecretStorageKey, cacheSecretStorageKey,
onSecretRequested, onSecretRequested,
getDehydrationKey,
}; };
export async function promptForBackupPassphrase() { export async function promptForBackupPassphrase() {
@ -262,6 +326,18 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
await cli.bootstrapSecretStorage({ await cli.bootstrapSecretStorage({
getKeyBackupPassphrase: promptForBackupPassphrase, 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 // `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; secretStorageBeingAccessed = false;
if (!isCachingAllowed()) { if (!isCachingAllowed()) {
secretStorageKeys = {}; 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 = {};
}
}
} }
} }
} }

View file

@ -16,12 +16,21 @@ limitations under the License.
*/ */
import {clamp} from "lodash"; 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 { export default class SendHistoryManager {
history: Array<HistoryItem> = []; history: Array<IHistoryItem> = [];
prefix: string; prefix: string;
lastIndex: number = 0; // used for indexing the storage lastIndex = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array currentIndex = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string) { constructor(roomId: string, prefix: string) {
this.prefix = prefix + roomId; this.prefix = prefix + roomId;
@ -32,8 +41,7 @@ export default class SendHistoryManager {
while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) { while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) {
try { try {
const serializedParts = JSON.parse(itemJSON); this.history.push(JSON.parse(itemJSON));
this.history.push(serializedParts);
} catch (e) { } catch (e) {
console.warn("Throwing away unserialisable history", e); console.warn("Throwing away unserialisable history", e);
break; break;
@ -45,15 +53,22 @@ export default class SendHistoryManager {
this.currentIndex = this.lastIndex + 1; this.currentIndex = this.lastIndex + 1;
} }
save(editorModel: Object) { static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem {
const serializedParts = editorModel.serializeParts(); return {
this.history.push(serializedParts); parts: model.serializeParts(),
this.currentIndex = this.history.length; replyEventId: replyEvent ? replyEvent.getId() : undefined,
this.lastIndex += 1; };
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts));
} }
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); this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex]; return this.history[this.currentIndex];
} }

View file

@ -14,12 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import CallHandler from './CallHandler';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import * as Roles from './Roles'; import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite"; import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore"; 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"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
function textForMemberEvent(ev) { function textForMemberEvent(ev) {
@ -29,7 +27,6 @@ function textForMemberEvent(ev) {
const prevContent = ev.getPrevContent(); const prevContent = ev.getPrevContent();
const content = ev.getContent(); const content = ev.getContent();
const ConferenceHandler = CallHandler.getConferenceHandler();
const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : '';
switch (content.membership) { switch (content.membership) {
case 'invite': { case 'invite': {
@ -43,14 +40,10 @@ function textForMemberEvent(ev) {
} else { } else {
return _t('%(targetName)s accepted an invitation.', {targetName}); 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 { } else {
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
} }
} }
}
case 'ban': case 'ban':
return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason; return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason;
case 'join': case 'join':
@ -85,17 +78,11 @@ function textForMemberEvent(ev) {
} }
} else { } else {
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); 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': case 'leave':
if (ev.getSender() === ev.getStateKey()) { if (ev.getSender() === ev.getStateKey()) {
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { if (prevContent.membership === "invite") {
return _t('VoIP conference finished.');
} else if (prevContent.membership === "invite") {
return _t('%(targetName)s rejected the invitation.', {targetName}); return _t('%(targetName)s rejected the invitation.', {targetName});
} else { } else {
return _t('%(targetName)s left the room.', {targetName}); 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: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
const {name, type, url} = event.getContent() || {}; 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 || ''; let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name // Apply sentence case to widget name
if (widgetName && widgetName.length > 0) { 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) { function textForMjolnirEvent(event) {
const senderName = event.getSender(); const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent(); const {entity: prevEntity} = event.getPrevContent();

View file

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

View file

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

View file

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

View file

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

View file

@ -166,7 +166,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
const onKeyDownHandler = useCallback((ev) => { const onKeyDownHandler = useCallback((ev) => {
let handled = false; 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 // check if we actually have any items
switch (ev.key) { switch (ev.key) {
case Key.HOME: case Key.HOME:

View file

@ -28,6 +28,9 @@ interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
const Toolbar: React.FC<IProps> = ({children, ...props}) => { const Toolbar: React.FC<IProps> = ({children, ...props}) => {
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
const target = ev.target as HTMLElement; const target = ev.target as HTMLElement;
// Don't interfere with input default keydown behaviour
if (target.tagName === "INPUT") return;
let handled = true; let handled = true;
// HOME and END are handled by RovingTabIndexProvider // HOME and END are handled by RovingTabIndexProvider

View file

@ -31,7 +31,7 @@ import AccessibleButton from "../../../../components/views/elements/AccessibleBu
import DialogButtons from "../../../../components/views/elements/DialogButtons"; import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; 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_LOADING = 0;
const PHASE_LOADERROR = 1; const PHASE_LOADERROR = 1;
@ -87,10 +87,16 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
canUploadKeysWithPasswordOnly: null, canUploadKeysWithPasswordOnly: null,
accountPassword: props.accountPassword || "", accountPassword: props.accountPassword || "",
accountPasswordCorrect: null, accountPasswordCorrect: null,
passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY,
canSkip: !isSecureBackupRequired(), 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._passphraseField = createRef();
this._fetchBackupInfo(); this._fetchBackupInfo();
@ -441,13 +447,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}); });
} }
_renderPhaseChooseKeyPassphrase() { _renderOptionKey() {
return <form onSubmit={this._onChooseKeyPassphraseFormSubmit}> return (
<p className="mx_CreateSecretStorageDialog_centeredBody">{_t(
"Safeguard against losing access to encrypted messages & data by " +
"backing up encryption keys on your server.",
)}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup" onChange={this._onKeyPassphraseChange}>
<StyledRadioButton <StyledRadioButton
key={CREATE_STORAGE_OPTION_KEY} key={CREATE_STORAGE_OPTION_KEY}
value={CREATE_STORAGE_OPTION_KEY} value={CREATE_STORAGE_OPTION_KEY}
@ -461,6 +462,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div> </div>
<div>{_t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}</div> <div>{_t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}</div>
</StyledRadioButton> </StyledRadioButton>
);
}
_renderOptionPassphrase() {
return (
<StyledRadioButton <StyledRadioButton
key={CREATE_STORAGE_OPTION_PASSPHRASE} key={CREATE_STORAGE_OPTION_PASSPHRASE}
value={CREATE_STORAGE_OPTION_PASSPHRASE} value={CREATE_STORAGE_OPTION_PASSPHRASE}
@ -474,6 +480,22 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div> </div>
<div>{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}</div> <div>{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}</div>
</StyledRadioButton> </StyledRadioButton>
);
}
_renderPhaseChooseKeyPassphrase() {
const setupMethods = getSecureBackupSetupMethods();
const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null;
const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null;
return <form onSubmit={this._onChooseKeyPassphraseFormSubmit}>
<p className="mx_CreateSecretStorageDialog_centeredBody">{_t(
"Safeguard against losing access to encrypted messages & data by " +
"backing up encryption keys on your server.",
)}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup" onChange={this._onKeyPassphraseChange}>
{optionKey}
{optionPassphrase}
</div> </div>
<DialogButtons <DialogButtons
primaryButton={_t("Continue")} primaryButton={_t("Continue")}

View file

@ -25,6 +25,7 @@ import EventIndexPeg from "../../indexing/EventIndexPeg";
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import BaseCard from "../views/right_panel/BaseCard"; import BaseCard from "../views/right_panel/BaseCard";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice";
/* /*
* Component which shows the filtered file using a TimelinePanel * Component which shows the filtered file using a TimelinePanel
@ -222,6 +223,8 @@ class FilePanel extends React.Component {
<p>{_t('Attach files from chat or just drag and drop them anywhere in a room.')}</p> <p>{_t('Attach files from chat or just drag and drop them anywhere in a room.')}</p>
</div>); </div>);
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
if (this.state.timelineSet) { if (this.state.timelineSet) {
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " + // console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); // "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
@ -232,6 +235,7 @@ class FilePanel extends React.Component {
previousPhase={RightPanelPhases.RoomSummary} previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer withoutScrollContainer
> >
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel <TimelinePanel
manageReadReceipts={false} manageReadReceipts={false}
manageReadMarkers={false} manageReadMarkers={false}

View file

@ -27,7 +27,6 @@ import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager'; import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index'; import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import sessionStore from '../../stores/SessionStore';
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg'; import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
@ -41,10 +40,6 @@ import HomePage from "./HomePage";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
import { DefaultTagID } from "../../stores/room-list/models"; import { DefaultTagID } from "../../stores/room-list/models";
import {
showToast as showSetPasswordToast,
hideToast as hideSetPasswordToast,
} from "../../toasts/SetPasswordToast";
import { import {
showToast as showServerLimitToast, showToast as showServerLimitToast,
hideToast as hideServerLimitToast, hideToast as hideServerLimitToast,
@ -85,7 +80,6 @@ interface IProps {
threepidInvite?: IThreepidInvite; threepidInvite?: IThreepidInvite;
roomOobData?: object; roomOobData?: object;
currentRoomId: string; currentRoomId: string;
ConferenceHandler?: object;
collapseLhs: boolean; collapseLhs: boolean;
config: { config: {
piwik: { piwik: {
@ -150,8 +144,6 @@ class LoggedInView extends React.Component<IProps, IState> {
protected readonly _matrixClient: MatrixClient; protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>; protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>; protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _sessionStore: sessionStore;
protected readonly _sessionStoreToken: { remove: () => void };
protected readonly _compactLayoutWatcherRef: string; protected readonly _compactLayoutWatcherRef: string;
protected resizer: Resizer; protected resizer: Resizer;
@ -172,12 +164,6 @@ class LoggedInView extends React.Component<IProps, IState> {
document.addEventListener('keydown', this._onNativeKeyDown, false); document.addEventListener('keydown', this._onNativeKeyDown, false);
this._sessionStore = sessionStore;
this._sessionStoreToken = this._sessionStore.addListener(
this._setStateFromSessionStore,
);
this._setStateFromSessionStore();
this._updateServerNoticeEvents(); this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData); this._matrixClient.on("accountData", this.onAccountData);
@ -206,9 +192,6 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef); SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
this.resizer.detach(); this.resizer.detach();
} }
@ -229,14 +212,6 @@ class LoggedInView extends React.Component<IProps, IState> {
return this._roomView.current.canResetTimeline(); return this._roomView.current.canResetTimeline();
}; };
_setStateFromSessionStore = () => {
if (this._sessionStore.getCachedPassword()) {
showSetPasswordToast();
} else {
hideSetPasswordToast();
}
};
_createResizer() { _createResizer() {
const classNames = { const classNames = {
handle: "mx_ResizeHandle", handle: "mx_ResizeHandle",
@ -637,7 +612,6 @@ class LoggedInView extends React.Component<IProps, IState> {
viaServers={this.props.viaServers} viaServers={this.props.viaServers}
key={this.props.currentRoomId || 'roomview'} key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled} disabled={this.props.middleDisabled}
ConferenceHandler={this.props.ConferenceHandler}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
/>; />;
break; break;

View file

@ -30,7 +30,7 @@ import 'what-input';
import Analytics from "../../Analytics"; import Analytics from "../../Analytics";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter"; import * as RoomListSorter from "../../RoomListSorter";
@ -80,6 +80,7 @@ import { leaveRoomBehaviour } from "../../utils/membership";
import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog";
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore"; import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
import {UIFeature} from "../../settings/UIFeature"; import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
export enum Views { export enum Views {
@ -148,7 +149,6 @@ interface IRoomInfo {
interface IProps { // TODO type things better interface IProps { // TODO type things better
config: Record<string, any>; config: Record<string, any>;
serverConfig?: ValidatedServerConfig; serverConfig?: ValidatedServerConfig;
ConferenceHandler?: any;
onNewScreen: (screen: string, replaceLast: boolean) => void; onNewScreen: (screen: string, replaceLast: boolean) => void;
enableGuest?: boolean; enableGuest?: boolean;
// the queryParams extracted from the [real] query-string of the URI // the queryParams extracted from the [real] query-string of the URI
@ -290,7 +290,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// When the session loads it'll be detected as soft logged out and a dispatch // 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 // will be sent out to say that, triggering this MatrixChat to show the soft
// logout page. // logout page.
Lifecycle.loadSession({}); Lifecycle.loadSession();
} }
this.accountPassword = null; this.accountPassword = null;
@ -670,9 +670,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'view_home_page': case 'view_home_page':
this.viewHome(); this.viewHome();
break; break;
case 'view_set_mxid':
this.setMxId(payload);
break;
case 'view_start_chat_or_reuse': case 'view_start_chat_or_reuse':
this.chatCreateOrReuse(payload.user_id); this.chatCreateOrReuse(payload.user_id);
break; break;
@ -985,37 +982,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
private setMxId(payload) { private async createRoom(defaultPublic = false) {
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { if (communityId) {
homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(), // double check the user will have permission to associate this room with the community
onFinished: (submitted, credentials) => { if (!CommunityPrototypeStore.instance.isAdminOf(communityId)) {
if (!submitted) { Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, {
dis.dispatch({ title: _t("Cannot create rooms in this community"),
action: 'cancel_after_sync_prepared', description: _t("You do not have permission to create rooms in this community."),
}); });
if (payload.go_home_on_cancel) {
dis.dispatch({
action: 'view_home_page',
});
}
return; 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 CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic }); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic });
@ -1802,12 +1781,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.showScreen("forgot_password"); this.showScreen("forgot_password");
}; };
onRegisterFlowComplete = (credentials: object, password: string) => { onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string) => {
return this.onUserCompletedLoginFlow(credentials, password); return this.onUserCompletedLoginFlow(credentials, password);
}; };
// returns a promise which resolves to the new MatrixClient // returns a promise which resolves to the new MatrixClient
onRegistered(credentials: object) { onRegistered(credentials: IMatrixClientCreds) {
return Lifecycle.setLoggedIn(credentials); return Lifecycle.setLoggedIn(credentials);
} }
@ -1843,7 +1822,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else { } else {
subtitle = `${this.subTitleStatus} ${subtitle}`; 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) { updateStatusIndicator(state: string, prevState: string) {
@ -1888,7 +1872,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* Note: SSO users (and any others using token login) currently do not pass through * 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`. * 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; this.accountPassword = password;
// self-destruct the password after 5mins // self-destruct the password after 5mins
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);

View file

@ -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 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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) { onRoomStateMember(ev, state, member) {
if (member.roomId !== this.props.room.roomId) { if (!this.props.room || member.roomId !== this.props.room.roomId) {
return; return;
} }
// redraw the badge on the membership list // redraw the badge on the membership list
@ -202,13 +199,19 @@ export default class RightPanel extends React.Component {
dis.dispatch({ dis.dispatch({
action: "view_home_page", 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 { } else {
// Otherwise we have got our user from RoomViewStore which means we're being shown // 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, // 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. // or the member list if we were in the member panel... phew.
const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
dis.dispatch({ dis.dispatch({
action: Action.ViewUser, action: Action.ViewUser,
member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null, member: isEncryptionPhase ? this.state.member : null,
}); });
} }
}; };

View file

@ -35,7 +35,7 @@ import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore"; import FlairStore from "../../stores/FlairStore";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160; const MAX_TOPIC_LENGTH = 800;
function track(action) { function track(action) {
Analytics.trackEvent('RoomDirectory', action); Analytics.trackEvent('RoomDirectory', action);
@ -497,6 +497,9 @@ export default class RoomDirectory extends React.Component {
} }
let topic = room.topic || ''; 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) { if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
} }

View file

@ -69,7 +69,6 @@ import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg"; import TintableSvg from "../views/elements/TintableSvg";
import type * as ConferenceHandler from '../../VectorConferenceHandler';
import {XOR} from "../../@types/common"; import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
@ -84,8 +83,6 @@ if (DEBUG) {
} }
interface IProps { interface IProps {
ConferenceHandler?: ConferenceHandler;
threepidInvite: IThreepidInvite, threepidInvite: IThreepidInvite,
// Any data about the room that would normally come from the homeserver // Any data about the room that would normally come from the homeserver
@ -181,7 +178,6 @@ export interface IState {
matrixClientIsReady: boolean; matrixClientIsReady: boolean;
showUrlPreview?: boolean; showUrlPreview?: boolean;
e2eStatus?: E2EStatus; e2eStatus?: E2EStatus;
displayConfCallNotification?: boolean;
rejecting?: boolean; rejecting?: boolean;
rejectError?: Error; rejectError?: Error;
} }
@ -488,8 +484,6 @@ export default class RoomView extends React.Component<IProps, IState> {
callState: callState, callState: callState,
}); });
this.updateConfCallNotification();
window.addEventListener('beforeunload', this.onPageUnload); window.addEventListener('beforeunload', this.onPageUnload);
if (this.props.resizeNotifier) { if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize); this.props.resizeNotifier.on("middlePanelResized", this.onResize);
@ -724,10 +718,6 @@ export default class RoomView extends React.Component<IProps, IState> {
callState = call.call_state; callState = call.call_state;
} }
// possibly remove the conf call notification if we're now in
// the conf
this.updateConfCallNotification();
this.setState({ this.setState({
callState: callState, callState: callState,
}); });
@ -1018,9 +1008,6 @@ export default class RoomView extends React.Component<IProps, IState> {
// rate limited because a power level change will emit an event for every member in the room. // rate limited because a power level change will emit an event for every member in the room.
private updateRoomMembers = rateLimitedFunc((dueToMember) => { private updateRoomMembers = rateLimitedFunc((dueToMember) => {
// a member state changed in this room
// refresh the conf call notification state
this.updateConfCallNotification();
this.updateDMState(); this.updateDMState();
let memberCountInfluence = 0; let memberCountInfluence = 0;
@ -1049,30 +1036,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.setState({isAlone: joinedOrInvitedMemberCount === 1}); 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() { private updateDMState() {
const room = this.state.room; const room = this.state.room;
if (room.getMyMembership() != "join") { if (room.getMyMembership() != "join") {
@ -1127,42 +1090,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room_id: this.getRoomId(), 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: '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 { } else {
Promise.resolve().then(() => { Promise.resolve().then(() => {
const signUrl = this.props.threepidInvite?.signUrl; const signUrl = this.props.threepidInvite?.signUrl;
@ -1681,7 +1609,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!this.state.room) { if (!this.state.room) {
return null; 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, // this has to be a proper method rather than an unnamed function,
@ -1857,7 +1785,6 @@ export default class RoomView extends React.Component<IProps, IState> {
let aux = null; let aux = null;
let previewBar; let previewBar;
let hideCancel = false; let hideCancel = false;
let forceHideRightPanel = false;
if (this.state.forwardingEvent) { if (this.state.forwardingEvent) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />; aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) { } else if (this.state.searching) {
@ -1866,6 +1793,7 @@ export default class RoomView extends React.Component<IProps, IState> {
searchInProgress={this.state.searchInProgress} searchInProgress={this.state.searchInProgress}
onCancelClick={this.onCancelSearchClick} onCancelClick={this.onCancelSearchClick}
onSearch={this.onSearch} onSearch={this.onSearch}
isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)}
/>; />;
} else if (showRoomUpgradeBar) { } else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />; aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
@ -1901,8 +1829,6 @@ export default class RoomView extends React.Component<IProps, IState> {
{ previewBar } { previewBar }
</div> </div>
); );
} else {
forceHideRightPanel = true;
} }
} else if (hiddenHighlightCount > 0) { } else if (hiddenHighlightCount > 0) {
aux = ( aux = (
@ -1924,9 +1850,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room} room={this.state.room}
fullHeight={false} fullHeight={false}
userId={this.context.credentials.userId} userId={this.context.credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile} draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight} maxHeight={this.state.auxPanelMaxHeight}
showApps={this.state.showApps} showApps={this.state.showApps}
hideAppsDrawer={false} hideAppsDrawer={false}
@ -2107,7 +2031,7 @@ export default class RoomView extends React.Component<IProps, IState> {
"mx_fadable_faded": this.props.disabled, "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 const rightPanel = showRightPanel
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} /> ? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
: null; : null;

View file

@ -343,6 +343,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
let secondarySection = null; let secondarySection = null;
if (prototypeCommunityName) { if (prototypeCommunityName) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
primaryHeader = ( primaryHeader = (
<div className="mx_UserMenu_contextMenu_name"> <div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName"> <span className="mx_UserMenu_contextMenu_displayName">
@ -350,24 +351,36 @@ export default class UserMenu extends React.Component<IProps, IState> {
</span> </span>
</div> </div>
); );
primaryOptionList = ( let settingsOption;
<IconizedContextMenuOptionList> let inviteOption;
if (CommunityPrototypeStore.instance.canInviteTo(communityId)) {
inviteOption = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconInvite"
label={_t("Invite")}
onClick={this.onCommunityInviteClick}
/>
);
}
if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
settingsOption = (
<IconizedContextMenuOption <IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings" iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")} label={_t("Settings")}
aria-label={_t("Community settings")} aria-label={_t("Community settings")}
onClick={this.onCommunitySettingsClick} onClick={this.onCommunitySettingsClick}
/> />
);
}
primaryOptionList = (
<IconizedContextMenuOptionList>
{settingsOption}
<IconizedContextMenuOption <IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMembers" iconClassName="mx_UserMenu_iconMembers"
label={_t("Members")} label={_t("Members")}
onClick={this.onCommunityMembersClick} onClick={this.onCommunityMembersClick}
/> />
<IconizedContextMenuOption {inviteOption}
iconClassName="mx_UserMenu_iconInvite"
label={_t("Invite")}
onClick={this.onCommunityInviteClick}
/>
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
); );
secondarySection = ( secondarySection = (

View file

@ -40,11 +40,7 @@ interface IProps {
onValidate(result: IValidationResult); onValidate(result: IValidationResult);
} }
interface IState { class PassphraseField extends PureComponent<IProps> {
complexity: zxcvbn.ZXCVBNResult;
}
class PassphraseField extends PureComponent<IProps, IState> {
static defaultProps = { static defaultProps = {
label: _td("Password"), label: _td("Password"),
labelEnterPassword: _td("Enter password"), labelEnterPassword: _td("Enter password"),
@ -52,14 +48,16 @@ class PassphraseField extends PureComponent<IProps, IState> {
labelAllowedButUnsafe: _td("Password is allowed, but unsafe"), labelAllowedButUnsafe: _td("Password is allowed, but unsafe"),
}; };
state = { complexity: null }; public readonly validate = withValidation<this, zxcvbn.ZXCVBNResult>({
description: function(complexity) {
public readonly validate = withValidation<this>({
description: function() {
const complexity = this.state.complexity;
const score = complexity ? complexity.score : 0; const score = complexity ? complexity.score : 0;
return <progress className="mx_PassphraseField_progress" max={4} value={score} />; return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
}, },
deriveData: async ({ value }) => {
if (!value) return null;
const { scorePassword } = await import('../../../utils/PasswordScorer');
return scorePassword(value);
},
rules: [ rules: [
{ {
key: "required", key: "required",
@ -68,28 +66,24 @@ class PassphraseField extends PureComponent<IProps, IState> {
}, },
{ {
key: "complexity", key: "complexity",
test: async function({ value }) { test: async function({ value }, complexity) {
if (!value) { if (!value) {
return false; return false;
} }
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
this.setState({ complexity });
const safe = complexity.score >= this.props.minScore; const safe = complexity.score >= this.props.minScore;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"]; const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
return allowUnsafe || safe; return allowUnsafe || safe;
}, },
valid: function() { valid: function(complexity) {
// Unsafe passwords that are valid are only possible through a // Unsafe passwords that are valid are only possible through a
// configuration flag. We'll print some helper text to signal // configuration flag. We'll print some helper text to signal
// to the user that their password is allowed, but unsafe. // 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.labelStrongPassword);
} }
return _t(this.props.labelAllowedButUnsafe); return _t(this.props.labelAllowedButUnsafe);
}, },
invalid: function() { invalid: function(complexity) {
const complexity = this.state.complexity;
if (!complexity) { if (!complexity) {
return null; return null;
} }

View file

@ -23,7 +23,7 @@ import {Action} from "../../../dispatcher/actions";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import BaseAvatar from "./BaseAvatar"; import BaseAvatar from "./BaseAvatar";
interface IProps { interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember; member: RoomMember;
fallbackUserId?: string; fallbackUserId?: string;
width: number; width: number;

View file

@ -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 = <InteractiveAuth
matrixClient={this._matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={{}}
poll={true}
ref={this._uiAuth}
continueIsManaged={true}
/>;
}
const inputClasses = classnames({
"mx_SetMxIdDialog_input": true,
"error": Boolean(this.state.usernameError),
});
let usernameIndicator = null;
if (this.state.usernameBusy) {
usernameIndicator = <div>{_t("Checking...")}</div>;
} else {
const usernameAvailable = this.state.username &&
this.state.usernameCheckSupport && !this.state.usernameError;
const usernameIndicatorClasses = classnames({
"error": Boolean(this.state.usernameError),
"success": usernameAvailable,
});
usernameIndicator = <div className={usernameIndicatorClasses} role="alert">
{ usernameAvailable ? _t('Username available') : this.state.usernameError }
</div>;
}
let authErrorIndicator = null;
if (this.state.authError) {
authErrorIndicator = <div className="error" role="alert">
{ this.state.authError }
</div>;
}
const canContinue = this.state.username &&
!this.state.usernameError &&
!this.state.usernameBusy;
return (
<BaseDialog className="mx_SetMxIdDialog"
onFinished={this.props.onFinished}
title={_t('To get started, please pick a username!')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<div className="mx_SetMxIdDialog_input_group">
<input type="text" ref={this._input_value} value={this.state.username}
autoFocus={true}
onChange={this.onValueChange}
onKeyUp={this.onKeyUp}
size="30"
className={inputClasses}
/>
</div>
{ usernameIndicator }
<p>
{ _t(
'This will be your account name on the <span></span> ' +
'homeserver, or you can pick a <a>different server</a>.',
{},
{
'span': <span>{ this.props.homeserverUrl }</span>,
'a': (sub) => <a href="#" onClick={this.props.onDifferentServerClicked}>{ sub }</a>,
},
) }
</p>
<p>
{ _t(
'If you already have a Matrix account you can <a>log in</a> instead.',
{},
{ 'a': (sub) => <a href="#" onClick={this.props.onLoginClick}>{ sub }</a> },
) }
</p>
{ auth }
{ authErrorIndicator }
</div>
<div className="mx_Dialog_buttons">
<input className="mx_Dialog_primary"
type="submit"
value={_t("Continue")}
onClick={this.onSubmit}
disabled={!canContinue}
/>
</div>
</BaseDialog>
);
}
}

View file

@ -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 <BaseDialog className="mx_SetPasswordDialog"
onFinished={props.onFinished}
title={ title }
>
<div className="mx_Dialog_content">
<p>
{ advice }
</p>
<p>
{ extraAdvice }
</p>
</div>
<div className="mx_Dialog_buttons">
<button
className="mx_Dialog_primary"
autoFocus={true}
onClick={props.onFinished}>
{ _t('Continue') }
</button>
</div>
</BaseDialog>;
};
/**
* 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 (
<BaseDialog className="mx_SetPasswordDialog"
onFinished={this.props.onFinished}
title={ _t('Please set a password!') }
>
<div className="mx_Dialog_content">
<p>
{ _t('This will allow you to return to your account after signing out, and sign in on other sessions.') }
</p>
<ChangePassword
className="mx_SetPasswordDialog_change_password"
rowClassName=""
buttonClassNames="mx_Dialog_primary mx_SetPasswordDialog_change_password_button"
buttonKind="primary"
confirm={false}
autoFocusNewPasswordInput={true}
shouldAskForEmail={true}
onError={this._onPasswordChangeError}
onFinished={this._onPasswordChanged} />
<div className="error">
{ this.state.error }
</div>
</div>
</BaseDialog>
);
}
}

View file

@ -289,7 +289,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
content = <div> content = <div>
<p>{_t("Use your Security Key to continue.")}</p> <p>{_t("Use your Security Key to continue.")}</p>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext} spellCheck={false}> <form
className="mx_AccessSecretStorageDialog_primaryContainer"
onSubmit={this._onRecoveryKeyNext}
spellCheck={false}
autoComplete="off"
>
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry"> <div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput"> <div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
<Field <Field
@ -298,6 +303,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
value={this.state.recoveryKey} value={this.state.recoveryKey}
onChange={this._onRecoveryKeyChange} onChange={this._onRecoveryKeyChange}
forceValidity={this.state.recoveryKeyCorrect} forceValidity={this.state.recoveryKeyCorrect}
autoComplete="off"
/> />
</div> </div>
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText"> <span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">

View file

@ -62,7 +62,8 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
}; };
render() { render() {
const {title, tooltip, children, tooltipClassName, ...props} = this.props; // eslint-disable-next-line @typescript-eslint/no-unused-vars
const {title, tooltip, children, tooltipClassName, forceHide, ...props} = this.props;
const tip = this.state.hover ? <Tooltip const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container" className="mx_AccessibleTooltipButton_container"

View file

@ -18,11 +18,9 @@ limitations under the License.
*/ */
import url from 'url'; import url from 'url';
import qs from 'qs';
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import WidgetMessaging from '../../../WidgetMessaging';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames'; import classNames from 'classnames';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement"; import PersistedElement from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType"; import {WidgetType} from "../../../widgets/WidgetType";
import {Capability} from "../../../widgets/WidgetApi";
import {sleep} from "../../../utils/promise";
import {SettingLevel} from "../../../settings/SettingLevel"; import {SettingLevel} from "../../../settings/SettingLevel";
import WidgetStore from "../../../stores/WidgetStore"; import WidgetStore from "../../../stores/WidgetStore";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
const ENABLE_REACT_PERF = false; import {MatrixCapabilities} from "matrix-widget-api";
/**
* Does template substitution on a URL (or any string). Variables will be
* passed through encodeURIComponent.
* @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'.
* @param {Object} variables The key/value pairs to replace the template
* variables with. E.g. { '$bar': 'baz' }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
function uriFromTemplate(uriTemplate, variables) {
let out = uriTemplate;
for (const [key, val] of Object.entries(variables)) {
out = out.replace(
'$' + key, encodeURIComponent(val),
);
}
return out;
}
export default class AppTile extends React.Component { export default class AppTile extends React.Component {
constructor(props) { constructor(props) {
@ -72,11 +49,14 @@ export default class AppTile extends React.Component {
// The key used for PersistedElement // The key used for PersistedElement
this._persistKey = 'widget_' + this.props.app.id; this._persistKey = 'widget_' + this.props.app.id;
this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props); this.state = this._getNewState(props);
this._onAction = this._onAction.bind(this); this._onAction = this._onAction.bind(this);
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this); this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this);
this._onRevokeClicked = this._onRevokeClicked.bind(this); this._onRevokeClicked = this._onRevokeClicked.bind(this);
@ -89,7 +69,6 @@ export default class AppTile extends React.Component {
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
this._contextMenuButton = createRef(); this._contextMenuButton = createRef();
this._appFrame = createRef();
this._menu_bar = createRef(); this._menu_bar = createRef();
} }
@ -108,12 +87,10 @@ export default class AppTile extends React.Component {
return !!currentlyAllowedWidgets[newProps.app.eventId]; return !!currentlyAllowedWidgets[newProps.app.eventId];
}; };
const PersistedElement = sdk.getComponent("elements.PersistedElement");
return { return {
initialising: true, // True while we are mangling the widget URL initialising: true, // True while we are mangling the widget URL
// True while the iframe content is loading // True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
widgetUrl: this._addWurlParams(newProps.app.url),
// Assume that widget has permission to load if we are the user who // Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user // added it to the room, or if explicitly granted by the user
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
@ -124,43 +101,6 @@ export default class AppTile extends React.Component {
}; };
} }
/**
* Does the widget support a given capability
* @param {string} capability Capability to check for
* @return {Boolean} True if capability supported
*/
_hasCapability(capability) {
return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability);
}
/**
* Add widget instance specific parameters to pass in wUrl
* Properties passed to widget instance:
* - widgetId
* - origin / parent URL
* @param {string} urlString Url string to modify
* @return {string}
* Url string with parameters appended.
* If url can not be parsed, it is returned unmodified.
*/
_addWurlParams(urlString) {
try {
const parsed = new URL(urlString);
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
parsed.searchParams.set('widgetId', this.props.app.id);
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
return parsed.toString().replace(/%24/g, '$');
} catch (e) {
console.error("Failed to add widget URL params:", e);
return urlString;
}
}
isMixedContent() { isMixedContent() {
const parentContentProtocol = window.location.protocol; const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url); const u = url.parse(this.props.app.url);
@ -176,7 +116,7 @@ export default class AppTile extends React.Component {
componentDidMount() { componentDidMount() {
// Only fetch IM token on mount if we're showing and have permission to load // Only fetch IM token on mount if we're showing and have permission to load
if (this.props.show && this.state.hasPermissionToLoad) { if (this.props.show && this.state.hasPermissionToLoad) {
this.setScalarToken(); this._startWidget();
} }
// Widget action listeners // Widget action listeners
@ -190,93 +130,45 @@ export default class AppTile extends React.Component {
// if it's not remaining on screen, get rid of the PersistedElement container // if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
} }
if (this._sgWidget) {
this._sgWidget.stop();
}
} }
// TODO: Generify the name of this function. It's not just scalar tokens. _resetWidget(newProps) {
/** if (this._sgWidget) {
* Adds a scalar token to the widget URL, if required this._sgWidget.stop();
* Component initialisation is only complete when this function has resolved }
*/ this._sgWidget = new StopGapWidget(newProps);
setScalarToken() { this._sgWidget.on("preparing", this._onWidgetPrepared);
if (!WidgetUtils.isScalarUrl(this.props.app.url)) { this._sgWidget.on("ready", this._onWidgetReady);
console.warn('Widget does not match integration manager, refusing to set auth token', url); this._startWidget();
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.app.url),
initialising: false,
});
return;
} }
const managers = IntegrationManagers.sharedInstance(); _startWidget() {
if (!managers.hasManager()) { this._sgWidget.prepare().then(() => {
console.warn("No integration manager - not setting scalar token", url); this.setState({initialising: false});
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.app.url),
initialising: false,
});
return;
}
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
console.warn('Unknown integration manager, refusing to set auth token', url);
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.app.url),
initialising: false,
});
return;
}
// Fetch the token before loading the iframe as we need it to mangle the URL
if (!this._scalarClient) {
this._scalarClient = defaultManager.getScalarClient();
}
this._scalarClient.getScalarToken().then((token) => {
// Append scalar_token as a query param if not already present
this._scalarClient.scalarToken = token;
const u = url.parse(this._addWurlParams(this.props.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,
});
}); });
} }
_iframeRefChange = (ref) => {
this.iframe = ref;
if (ref) {
this._sgWidget.start(ref);
} else {
this._resetWidget(this.props);
}
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
if (nextProps.app.url !== this.props.app.url) { if (nextProps.app.url !== this.props.app.url) {
this._getNewState(nextProps); 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) { 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, 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) { if (this.state.hasPermissionToLoad) {
this.setScalarToken(); this._startWidget();
} }
} }
@ -319,7 +211,14 @@ export default class AppTile extends React.Component {
} }
_onSnapshotClick() { _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 * @private
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
*/ */
_endWidgetActions() { async _endWidgetActions() { // widget migration dev note: async to maintain signature
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();
}
return terminationPromise.finally(() => {
// HACK: This is a really dirty way to ensure that Jitsi cleans up // 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 // 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 // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
if (this._appFrame.current) { if (this.iframe) {
// In practice we could just do `+= ''` to trick the browser // In practice we could just do `+= ''` to trick the browser
// into thinking the URL changed, however I can foresee this // into thinking the URL changed, however I can foresee this
// being optimized out by a browser. Instead, we'll just point // being optimized out by a browser. Instead, we'll just point
// the iframe at a page that is reasonably safe to use in the // the iframe at a page that is reasonably safe to use in the
// event the iframe doesn't wink away. // event the iframe doesn't wink away.
// This is relative to where the Element instance is located. // This is relative to where the Element instance is located.
this._appFrame.current.src = 'about:blank'; this.iframe.src = 'about:blank';
} }
// Delete the widget from the persisted store for good measure. // Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
});
this._sgWidget.stop();
} }
/* If user has permission to modify widgets, delete the widget, /* If user has permission to modify widgets, delete the widget,
@ -409,73 +297,21 @@ export default class AppTile extends React.Component {
this._revokeWidgetPermission(); this._revokeWidgetPermission();
} }
/** _onWidgetPrepared = () => {
* 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);
this.setState({loading: false}); this.setState({loading: false});
} };
_setupWidgetMessaging() { _onWidgetReady = () => {
// 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)) { if (WidgetType.JITSI.matches(this.props.app.type)) {
widgetMessaging.flagReadyToContinue(); this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
}
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err);
});
} }
};
_onAction(payload) { _onAction(payload) {
if (payload.widgetId === this.props.app.id) { if (payload.widgetId === this.props.app.id) {
switch (payload.action) { switch (payload.action) {
case 'm.sticker': case 'm.sticker':
if (this._hasCapability('m.sticker')) { if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({action: 'post_sticker_message', data: payload.data}); dis.dispatch({action: 'post_sticker_message', data: payload.data});
} else { } else {
console.warn('Ignoring sticker message. Invalid capability'); 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() { _grantWidgetPermission() {
const roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId); 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}); this.setState({hasPermissionToLoad: true});
// Fetch a token for the integration manager, now that we're allowed to // Fetch a token for the integration manager, now that we're allowed to
this.setScalarToken(); this._startWidget();
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
// We don't really need to do anything about this - the user will just hit the button again. // 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); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement"); const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop();
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
// We don't really need to do anything about this - the user will just hit the button again. // 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 * Whether we're using a local version of the widget rather than loading the
* actual widget URL * actual widget URL
@ -615,67 +404,11 @@ export default class AppTile extends React.Component {
return WidgetType.JITSI.matches(this.props.app.type); 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() { _getTileTitle() {
const name = this.formatAppTileName(); const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>; const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = ''; let title = '';
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
title = this.state.widgetPageTitle; 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). // 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) { if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
this._endWidgetActions().then(() => { this._endWidgetActions().then(() => {
if (this._appFrame.current) { if (this.iframe) {
// Reload iframe // Reload iframe
this._appFrame.current.src = this._getRenderedUrl(); this.iframe.src = this._sgWidget.embedUrl;
this.setState({}); 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. // Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'), 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() { _onReloadWidgetClick() {
// Reload iframe in this way to avoid cross-origin restrictions // Reload iframe in this way to avoid cross-origin restrictions
// eslint-disable-next-line no-self-assign // eslint-disable-next-line no-self-assign
this._appFrame.current.src = this._appFrame.current.src; this.iframe.src = this.iframe.src;
} }
_onContextMenuClick = () => { _onContextMenuClick = () => {
@ -760,7 +493,7 @@ export default class AppTile extends React.Component {
<AppPermission <AppPermission
roomId={this.props.room.roomId} roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId} creatorUserId={this.props.creatorUserId}
url={this.state.widgetUrl} url={this._sgWidget.embedUrl}
isRoomEncrypted={isEncrypted} isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission} onPermissionGranted={this._grantWidgetPermission}
/> />
@ -785,11 +518,11 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement } { this.state.loading && loadingElement }
<iframe <iframe
allow={iframeFeatures} allow={iframeFeatures}
ref={this._appFrame} ref={this._iframeRefChange}
src={this._getRenderedUrl()} src={this._sgWidget.embedUrl}
allowFullScreen={true} allowFullScreen={true}
sandbox={sandboxFlags} sandbox={sandboxFlags}
onLoad={this._onLoaded} /> />
</div> </div>
); );
// if the widget would be allowed to remain on screen, we must put it in // if the widget would be allowed to remain on screen, we must put it in
@ -833,9 +566,10 @@ export default class AppTile extends React.Component {
const elementRect = this._contextMenuButton.current.getBoundingClientRect(); const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const canUserModify = this._canUserModify(); const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._scalarClient && canUserModify); const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify; const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show; const showPictureSnapshotButton = this.props.show && this._sgWidget.widgetApi &&
this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots);
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = ( contextMenu = (
@ -943,9 +677,6 @@ AppTile.propTypes = {
// NOTE -- Use with caution. This is intended to aid better integration / UX // NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events. // basic widget capabilities, e.g. injecting sticker message events.
whitelistCapabilities: PropTypes.array, whitelistCapabilities: PropTypes.array,
// Optional function to be called on widget capability request
// Called with an array of the requested capabilities
onCapabilityRequest: PropTypes.func,
// Is this an instance of a user widget // Is this an instance of a user widget
userWidget: PropTypes.bool, userWidget: PropTypes.bool,
}; };

View file

@ -0,0 +1,77 @@
/*
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 EventIndexPeg from "../../../indexing/EventIndexPeg";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import React from "react";
export enum WarningKind {
Files,
Search,
}
interface IProps {
isRoomEncrypted: boolean;
kind: WarningKind;
}
export default function DesktopBuildsNotice({isRoomEncrypted, kind}: IProps) {
if (!isRoomEncrypted) return null;
if (EventIndexPeg.get()) return null;
const {desktopBuilds, brand} = SdkConfig.get();
let text = null;
let logo = null;
if (desktopBuilds.available) {
logo = <img src={desktopBuilds.logo} />;
switch (kind) {
case WarningKind.Files:
text = _t("Use the <a>Desktop app</a> to see all encrypted files", {}, {
a: sub => (<a href={desktopBuilds.url} target="_blank" rel="noreferrer noopener">{sub}</a>),
});
break;
case WarningKind.Search:
text = _t("Use the <a>Desktop app</a> to search encrypted messages", {}, {
a: sub => (<a href={desktopBuilds.url} target="_blank" rel="noreferrer noopener">{sub}</a>),
});
break;
}
} else {
switch (kind) {
case WarningKind.Files:
text = _t("This version of %(brand)s does not support viewing some encrypted files", {brand});
break;
case WarningKind.Search:
text = _t("This version of %(brand)s does not support searching encrypted messages", {brand});
break;
}
}
// for safety
if (!text) {
console.warn("Unknown desktop builds warning kind: ", kind);
return null;
}
return (
<div className="mx_DesktopBuildsNotice">
{logo}
<span>{text}</span>
</div>
);
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,15 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useEffect} from 'react'; import React, {ReactChildren, useEffect} from 'react';
import PropTypes from 'prop-types'; import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {MatrixEvent, RoomMember} from "matrix-js-sdk";
import {useStateToggle} from "../../../hooks/useStateToggle"; import {useStateToggle} from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
const EventListSummary = ({events, children, threshold=3, onToggle, startExpanded, summaryMembers=[], summaryText}) => { interface IProps {
// An array of member events to summarise
events: MatrixEvent[];
// The minimum number of events needed to trigger summarisation
threshold?: number;
// Whether or not to begin with state.expanded=true
startExpanded?: boolean,
// The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[],
// The text to show as the summary of this event list
summaryText?: string,
// An array of EventTiles to render when expanded
children: ReactChildren,
// Called when the event list expansion is toggled
onToggle?(): void;
}
const EventListSummary: React.FC<IProps> = ({
events,
children,
threshold = 3,
onToggle,
startExpanded,
summaryMembers = [],
summaryText,
}) => {
const [expanded, toggleExpanded] = useStateToggle(startExpanded); const [expanded, toggleExpanded] = useStateToggle(startExpanded);
// Whenever expanded changes call onToggle // Whenever expanded changes call onToggle
@ -75,22 +101,4 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande
); );
}; };
EventListSummary.propTypes = {
// An array of member events to summarise
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
// An array of EventTiles to render when expanded
children: PropTypes.arrayOf(PropTypes.element).isRequired,
// The minimum number of events needed to trigger summarisation
threshold: PropTypes.number,
// Called when the event list expansion is toggled
onToggle: PropTypes.func,
// Whether or not to begin with state.expanded=true
startExpanded: PropTypes.bool,
// The list of room members for which to show avatars next to the summary
summaryMembers: PropTypes.arrayOf(PropTypes.instanceOf(RoomMember)),
// The text to show as the summary of this event list
summaryText: PropTypes.string,
};
export default EventListSummary; export default EventListSummary;

View file

@ -80,27 +80,30 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
private fakeEvent({userId, displayname, avatar_url: avatarUrl}: IState) { private fakeEvent({userId, displayname, avatar_url: avatarUrl}: IState) {
// Fake it till we make it // Fake it till we make it
const event = new MatrixEvent(JSON.parse(`{ /* eslint-disable quote-props */
"type": "m.room.message", const rawEvent = {
"sender": "${userId}", type: "m.room.message",
"content": { sender: userId,
content: {
"m.new_content": { "m.new_content": {
"msgtype": "m.text", msgtype: "m.text",
"body": "${this.props.message}", body: this.props.message,
"displayname": "${displayname}", displayname: displayname,
"avatar_url": "${avatarUrl}" avatar_url: avatarUrl,
}, },
"msgtype": "m.text", msgtype: "m.text",
"body": "${this.props.message}", body: this.props.message,
"displayname": "${displayname}", displayname: displayname,
"avatar_url": "${avatarUrl}" avatar_url: avatarUrl,
}, },
"unsigned": { unsigned: {
"age": 97 age: 97,
}, },
"event_id": "$9999999999999999999999999999999999999999999", event_id: "$9999999999999999999999999999999999999999999",
"room_id": "!999999999999999999:matrix.org" room_id: "!999999999999999999:example.org",
}`)); };
const event = new MatrixEvent(rawEvent);
/* eslint-enable quote-props */
// Fake it more // Fake it more
event.sender = { event.sender = {

View file

@ -16,32 +16,60 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ReactChildren } from 'react';
import PropTypes from 'prop-types'; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import * as sdk from "../../../index"; import { isValid3pidInvite } from "../../../RoomInvite";
import {MatrixEvent} from "matrix-js-sdk"; import EventListSummary from "./EventListSummary";
import {isValid3pidInvite} from "../../../RoomInvite";
export default class MemberEventListSummary extends React.Component { interface IProps {
static propTypes = {
// An array of member events to summarise // An array of member events to summarise
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired, events: MatrixEvent[];
// An array of EventTiles to render when expanded
children: PropTypes.array.isRequired,
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength: PropTypes.number, summaryLength?: number;
// The maximum number of avatars to display in the summary // The maximum number of avatars to display in the summary
avatarsMaxLength: PropTypes.number, avatarsMaxLength?: number;
// The minimum number of events needed to trigger summarisation // The minimum number of events needed to trigger summarisation
threshold: PropTypes.number, threshold?: number,
// Called when the MELS expansion is toggled
onToggle: PropTypes.func,
// Whether or not to begin with state.expanded=true // Whether or not to begin with state.expanded=true
startExpanded: PropTypes.bool, startExpanded?: boolean,
}; // An array of EventTiles to render when expanded
children: ReactChildren;
// Called when the MELS expansion is toggled
onToggle?(): void,
}
interface IUserEvents {
// The original event
mxEvent: MatrixEvent;
// The display name of the user (if not, then user ID)
displayName: string;
// The original index of the event in this.props.events
index: number;
}
enum TransitionType {
Joined = "joined",
Left = "left",
JoinedAndLeft = "joined_and_left",
LeftAndJoined = "left_and_joined",
InviteReject = "invite_reject",
InviteWithdrawal = "invite_withdrawal",
Invited = "invited",
Banned = "banned",
Unbanned = "unbanned",
Kicked = "kicked",
ChangedName = "changed_name",
ChangedAvatar = "changed_avatar",
NoChange = "no_change",
}
const SEP = ",";
export default class MemberEventListSummary extends React.Component<IProps> {
static defaultProps = { static defaultProps = {
summaryLength: 1, summaryLength: 1,
threshold: 3, threshold: 3,
@ -62,30 +90,28 @@ export default class MemberEventListSummary extends React.Component {
/** /**
* Generate the text for users aggregated by their transition sequences (`eventAggregates`) where * Generate the text for users aggregated by their transition sequences (`eventAggregates`) where
* the sequences are ordered by `orderedTransitionSequences`. * the sequences are ordered by `orderedTransitionSequences`.
* @param {object[]} eventAggregates a map of transition sequence to array of user display names * @param {object} eventAggregates a map of transition sequence to array of user display names
* or user IDs. * or user IDs.
* @param {string[]} orderedTransitionSequences an array which is some ordering of * @param {string[]} orderedTransitionSequences an array which is some ordering of
* `Object.keys(eventAggregates)`. * `Object.keys(eventAggregates)`.
* @returns {string} the textual summary of the aggregated events that occurred. * @returns {string} the textual summary of the aggregated events that occurred.
*/ */
_generateSummary(eventAggregates, orderedTransitionSequences) { private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
const summaries = orderedTransitionSequences.map((transitions) => { const summaries = orderedTransitionSequences.map((transitions) => {
const userNames = eventAggregates[transitions]; const userNames = eventAggregates[transitions];
const nameList = this._renderNameList(userNames); const nameList = this.renderNameList(userNames);
const splitTransitions = transitions.split(','); const splitTransitions = transitions.split(SEP) as TransitionType[];
// Some neighbouring transitions are common, so canonicalise some into "pair" // Some neighbouring transitions are common, so canonicalise some into "pair"
// transitions // transitions
const canonicalTransitions = this._getCanonicalTransitions(splitTransitions); const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions);
// Transform into consecutive repetitions of the same transition (like 5 // Transform into consecutive repetitions of the same transition (like 5
// consecutive 'joined_and_left's) // consecutive 'joined_and_left's)
const coalescedTransitions = this._coalesceRepeatedTransitions( const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
canonicalTransitions,
);
const descs = coalescedTransitions.map((t) => { const descs = coalescedTransitions.map((t) => {
return this._getDescriptionForTransition( return MemberEventListSummary.getDescriptionForTransition(
t.transitionType, userNames.length, t.repeats, t.transitionType, userNames.length, t.repeats,
); );
}); });
@ -108,7 +134,7 @@ export default class MemberEventListSummary extends React.Component {
* more items in `users` than `this.props.summaryLength`, which is the number of names * more items in `users` than `this.props.summaryLength`, which is the number of names
* included before "and [n] others". * included before "and [n] others".
*/ */
_renderNameList(users) { private renderNameList(users: string[]) {
return formatCommaSeparatedList(users, this.props.summaryLength); return formatCommaSeparatedList(users, this.props.summaryLength);
} }
@ -119,22 +145,22 @@ export default class MemberEventListSummary extends React.Component {
* @param {string[]} transitions an array of transitions. * @param {string[]} transitions an array of transitions.
* @returns {string[]} an array of transitions. * @returns {string[]} an array of transitions.
*/ */
_getCanonicalTransitions(transitions) { private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
const modMap = { const modMap = {
'joined': { [TransitionType.Joined]: {
'after': 'left', after: TransitionType.Left,
'newTransition': 'joined_and_left', newTransition: TransitionType.JoinedAndLeft,
}, },
'left': { [TransitionType.Left]: {
'after': 'joined', after: TransitionType.Joined,
'newTransition': 'left_and_joined', newTransition: TransitionType.LeftAndJoined,
}, },
// $currentTransition : { // $currentTransition : {
// 'after' : $nextTransition, // 'after' : $nextTransition,
// 'newTransition' : 'new_transition_type', // 'newTransition' : 'new_transition_type',
// }, // },
}; };
const res = []; const res: TransitionType[] = [];
for (let i = 0; i < transitions.length; i++) { for (let i = 0; i < transitions.length; i++) {
const t = transitions[i]; const t = transitions[i];
@ -166,8 +192,12 @@ export default class MemberEventListSummary extends React.Component {
* @param {string[]} transitions the array of transitions to transform. * @param {string[]} transitions the array of transitions to transform.
* @returns {object[]} an array of coalesced transitions. * @returns {object[]} an array of coalesced transitions.
*/ */
_coalesceRepeatedTransitions(transitions) { private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
const res = []; const res: {
transitionType: TransitionType;
repeats: number;
}[] = [];
for (let i = 0; i < transitions.length; i++) { for (let i = 0; i < transitions.length; i++) {
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
res[res.length - 1].repeats += 1; res[res.length - 1].repeats += 1;
@ -189,7 +219,7 @@ export default class MemberEventListSummary extends React.Component {
* @param {number} repeats the number of times the transition was repeated in a row. * @param {number} repeats the number of times the transition was repeated in a row.
* @returns {string} the written Human Readable equivalent of the transition. * @returns {string} the written Human Readable equivalent of the transition.
*/ */
_getDescriptionForTransition(t, userCount, repeats) { private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
// The empty interpolations 'severalUsers' and 'oneUser' // The empty interpolations 'severalUsers' and 'oneUser'
// are there only to show translators to non-English languages // are there only to show translators to non-English languages
// that the verb is conjugated to plural or singular Subject. // that the verb is conjugated to plural or singular Subject.
@ -217,12 +247,18 @@ export default class MemberEventListSummary extends React.Component {
break; break;
case "invite_reject": case "invite_reject":
res = (userCount > 1) res = (userCount > 1)
? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats }) ? _t("%(severalUsers)srejected their invitations %(count)s times", {
severalUsers: "",
count: repeats,
})
: _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats }); : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats });
break; break;
case "invite_withdrawal": case "invite_withdrawal":
res = (userCount > 1) res = (userCount > 1)
? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats }) ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", {
severalUsers: "",
count: repeats,
})
: _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats }); : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats });
break; break;
case "invited": case "invited":
@ -265,8 +301,8 @@ export default class MemberEventListSummary extends React.Component {
return res; return res;
} }
_getTransitionSequence(events) { private static getTransitionSequence(events: MatrixEvent[]) {
return events.map(this._getTransition); return events.map(MemberEventListSummary.getTransition);
} }
/** /**
@ -277,60 +313,60 @@ export default class MemberEventListSummary extends React.Component {
* @returns {string?} the transition type given to this event. This defaults to `null` * @returns {string?} the transition type given to this event. This defaults to `null`
* if a transition is not recognised. * if a transition is not recognised.
*/ */
_getTransition(e) { private static getTransition(e: MatrixEvent): TransitionType {
if (e.mxEvent.getType() === 'm.room.third_party_invite') { if (e.mxEvent.getType() === 'm.room.third_party_invite') {
// Handle 3pid invites the same as invites so they get bundled together // Handle 3pid invites the same as invites so they get bundled together
if (!isValid3pidInvite(e.mxEvent)) { if (!isValid3pidInvite(e.mxEvent)) {
return 'invite_withdrawal'; return TransitionType.InviteWithdrawal;
} }
return 'invited'; return TransitionType.Invited;
} }
switch (e.mxEvent.getContent().membership) { switch (e.mxEvent.getContent().membership) {
case 'invite': return 'invited'; case 'invite': return TransitionType.Invited;
case 'ban': return 'banned'; case 'ban': return TransitionType.Banned;
case 'join': case 'join':
if (e.mxEvent.getPrevContent().membership === 'join') { if (e.mxEvent.getPrevContent().membership === 'join') {
if (e.mxEvent.getContent().displayname !== if (e.mxEvent.getContent().displayname !==
e.mxEvent.getPrevContent().displayname) { e.mxEvent.getPrevContent().displayname) {
return 'changed_name'; return TransitionType.ChangedName;
} else if (e.mxEvent.getContent().avatar_url !== } else if (e.mxEvent.getContent().avatar_url !==
e.mxEvent.getPrevContent().avatar_url) { e.mxEvent.getPrevContent().avatar_url) {
return 'changed_avatar'; return TransitionType.ChangedAvatar;
} }
// console.log("MELS ignoring duplicate membership join event"); // console.log("MELS ignoring duplicate membership join event");
return 'no_change'; return TransitionType.NoChange;
} else { } else {
return 'joined'; return TransitionType.Joined;
} }
case 'leave': case 'leave':
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
switch (e.mxEvent.getPrevContent().membership) { switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return 'invite_reject'; case 'invite': return TransitionType.InviteReject;
default: return 'left'; default: return TransitionType.Left;
} }
} }
switch (e.mxEvent.getPrevContent().membership) { switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return 'invite_withdrawal'; case 'invite': return TransitionType.InviteWithdrawal;
case 'ban': return 'unbanned'; case 'ban': return TransitionType.Unbanned;
// sender is not target and made the target leave, if not from invite/ban then this is a kick // sender is not target and made the target leave, if not from invite/ban then this is a kick
default: return 'kicked'; default: return TransitionType.Kicked;
} }
default: return null; default: return null;
} }
} }
_getAggregate(userEvents) { getAggregate(userEvents: Record<string, IUserEvents[]>) {
// A map of aggregate type to arrays of display names. Each aggregate type // A map of aggregate type to arrays of display names. Each aggregate type
// is a comma-delimited string of transitions, e.g. "joined,left,kicked". // is a comma-delimited string of transitions, e.g. "joined,left,kicked".
// The array of display names is the array of users who went through that // The array of display names is the array of users who went through that
// sequence during eventsToRender. // sequence during eventsToRender.
const aggregate = { const aggregate: Record<string, string[]> = {
// $aggregateType : []:string // $aggregateType : []:string
}; };
// A map of aggregate types to the indices that order them (the index of // A map of aggregate types to the indices that order them (the index of
// the first event for a given transition sequence) // the first event for a given transition sequence)
const aggregateIndices = { const aggregateIndices: Record<string, number> = {
// $aggregateType : int // $aggregateType : int
}; };
@ -340,7 +376,7 @@ export default class MemberEventListSummary extends React.Component {
const firstEvent = userEvents[userId][0]; const firstEvent = userEvents[userId][0];
const displayName = firstEvent.displayName; const displayName = firstEvent.displayName;
const seq = this._getTransitionSequence(userEvents[userId]); const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
if (!aggregate[seq]) { if (!aggregate[seq]) {
aggregate[seq] = []; aggregate[seq] = [];
aggregateIndices[seq] = -1; aggregateIndices[seq] = -1;
@ -349,7 +385,8 @@ export default class MemberEventListSummary extends React.Component {
aggregate[seq].push(displayName); aggregate[seq].push(displayName);
if (aggregateIndices[seq] === -1 || if (aggregateIndices[seq] === -1 ||
firstEvent.index < aggregateIndices[seq]) { firstEvent.index < aggregateIndices[seq]
) {
aggregateIndices[seq] = firstEvent.index; aggregateIndices[seq] = firstEvent.index;
} }
}, },
@ -364,25 +401,21 @@ export default class MemberEventListSummary extends React.Component {
render() { render() {
const eventsToRender = this.props.events; const eventsToRender = this.props.events;
// Map user IDs to an array of objects: // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
const userEvents = { // so this works perfectly for us to match event order whilst storing the latest Avatar Member
// $userId : [{ const latestUserAvatarMember = new Map<string, RoomMember>();
// // The original event
// mxEvent: e,
// // The display name of the user (if not, then user ID)
// displayName: e.target.name || userId,
// // The original index of the event in this.props.events
// index: index,
// }]
};
const avatarMembers = []; // Object mapping user IDs to an array of IUserEvents
const userEvents: Record<string, IUserEvents[]> = {};
eventsToRender.forEach((e, index) => { eventsToRender.forEach((e, index) => {
const userId = e.getStateKey(); const userId = e.getStateKey();
// Initialise a user's events // Initialise a user's events
if (!userEvents[userId]) { if (!userEvents[userId]) {
userEvents[userId] = []; userEvents[userId] = [];
if (e.target) avatarMembers.push(e.target); }
if (e.target) {
latestUserAvatarMember.set(userId, e.target);
} }
let displayName = userId; let displayName = userId;
@ -399,21 +432,20 @@ export default class MemberEventListSummary extends React.Component {
}); });
}); });
const aggregate = this._getAggregate(userEvents); const aggregate = this.getAggregate(userEvents);
// Sort types by order of lowest event index within sequence // Sort types by order of lowest event index within sequence
const orderedTransitionSequences = Object.keys(aggregate.names).sort( const orderedTransitionSequences = Object.keys(aggregate.names).sort(
(seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2], (seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2],
); );
const EventListSummary = sdk.getComponent("views.elements.EventListSummary");
return <EventListSummary return <EventListSummary
events={this.props.events} events={this.props.events}
threshold={this.props.threshold} threshold={this.props.threshold}
onToggle={this.props.onToggle} onToggle={this.props.onToggle}
startExpanded={this.props.startExpanded} startExpanded={this.props.startExpanded}
children={this.props.children} children={this.props.children}
summaryMembers={avatarMembers} summaryMembers={[...latestUserAvatarMember.values()]}
summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />; summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
} }
} }

View file

@ -82,6 +82,7 @@ export default class PersistentApp extends React.Component {
showDelete={false} showDelete={false}
showMinimise={false} showMinimise={false}
miniMode={true} miniMode={true}
showMenubar={false}
/>; />;
} }
} }

View file

@ -29,6 +29,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import {PERMITTED_URL_SCHEMES} from "../../../HtmlUtils";
// This component does no cycle detection, simply because the only way to make such a cycle would be to // This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
@ -62,6 +63,12 @@ export default class ReplyThread extends React.Component {
err: false, err: false,
}; };
this.unmounted = false;
this.context.on("Event.replaced", this.onEventReplaced);
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.room.on("Room.redaction", this.onRoomRedaction);
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
this.onQuoteClick = this.onQuoteClick.bind(this); this.onQuoteClick = this.onQuoteClick.bind(this);
this.canCollapse = this.canCollapse.bind(this); this.canCollapse = this.canCollapse.bind(this);
this.collapse = this.collapse.bind(this); this.collapse = this.collapse.bind(this);
@ -106,6 +113,9 @@ export default class ReplyThread extends React.Component {
{ {
allowedTags: false, // false means allow everything allowedTags: false, // false means allow everything
allowedAttributes: false, allowedAttributes: false,
// we somehow can't allow all schemes, so we allow all that we
// know of and mxc (for img tags)
allowedSchemes: [...PERMITTED_URL_SCHEMES, 'mxc'],
exclusiveFilter: (frame) => frame.tag === "mx-reply", exclusiveFilter: (frame) => frame.tag === "mx-reply",
}, },
); );
@ -213,11 +223,6 @@ export default class ReplyThread extends React.Component {
} }
componentDidMount() { componentDidMount() {
this.unmounted = false;
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.room.on("Room.redaction", this.onRoomRedaction);
// same event handler as Room.redaction as for both we just do forceUpdate
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
this.initialize(); this.initialize();
} }
@ -227,21 +232,36 @@ export default class ReplyThread extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener("Event.replaced", this.onEventReplaced);
if (this.room) { if (this.room) {
this.room.removeListener("Room.redaction", this.onRoomRedaction); this.room.removeListener("Room.redaction", this.onRoomRedaction);
this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction); this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
} }
} }
onRoomRedaction = (ev, room) => { updateForEventId = (eventId) => {
if (this.unmounted) return; if (this.state.events.some(event => event.getId() === eventId)) {
// If one of the events we are rendering gets redacted, force a re-render
if (this.state.events.some(event => event.getId() === ev.getId())) {
this.forceUpdate(); this.forceUpdate();
} }
}; };
onEventReplaced = (ev) => {
if (this.unmounted) return;
// If one of the events we are rendering gets replaced, force a re-render
this.updateForEventId(ev.getId());
};
onRoomRedaction = (ev) => {
if (this.unmounted) return;
const eventId = ev.getAssociatedId();
if (!eventId) return;
// If one of the events we are rendering gets redacted, force a re-render
this.updateForEventId(eventId);
};
async initialize() { async initialize() {
const {parentEv} = this.props; const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // at time of making this component we checked that props.parentEv has a parentEventId
@ -368,6 +388,7 @@ export default class ReplyThread extends React.Component {
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
useIRCLayout={this.props.useIRCLayout} useIRCLayout={this.props.useIRCLayout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)} enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
/> />
</blockquote>; </blockquote>;
}); });

View file

@ -21,18 +21,19 @@ import classNames from "classnames";
type Data = Pick<IFieldState, "value" | "allowEmpty">; type Data = Pick<IFieldState, "value" | "allowEmpty">;
interface IRule<T> { interface IRule<T, D = void> {
key: string; key: string;
final?: boolean; final?: boolean;
skip?(this: T, data: Data): boolean; skip?(this: T, data: Data, derivedData: D): boolean;
test(this: T, data: Data): boolean | Promise<boolean>; test(this: T, data: Data, derivedData: D): boolean | Promise<boolean>;
valid?(this: T): string; valid?(this: T, derivedData: D): string;
invalid?(this: T): string; invalid?(this: T, derivedData: D): string;
} }
interface IArgs<T> { interface IArgs<T, D = void> {
rules: IRule<T>[]; rules: IRule<T, D>[];
description(this: T): React.ReactChild; description(this: T, derivedData: D): React.ReactChild;
deriveData?(data: Data): Promise<D>;
} }
export interface IFieldState { export interface IFieldState {
@ -53,6 +54,10 @@ export interface IValidationResult {
* @param {Function} description * @param {Function} description
* Function that returns a string summary of the kind of value that will * Function that returns a string summary of the kind of value that will
* meet the validation rules. Shown at the top of the validation feedback. * meet the validation rules. Shown at the top of the validation feedback.
* @param {Function} deriveData
* Optional function that returns a Promise to an object of generic type D.
* The result of this Promise is passed to rule methods `skip`, `test`, `valid`, and `invalid`.
* Useful for doing calculations per-value update once rather than in each of the above rule methods.
* @param {Object} rules * @param {Object} rules
* An array of rules describing how to check to input value. Each rule in an object * An array of rules describing how to check to input value. Each rule in an object
* and may have the following properties: * and may have the following properties:
@ -66,7 +71,7 @@ export interface IValidationResult {
* A validation function that takes in the current input value and returns * A validation function that takes in the current input value and returns
* the overall validity and a feedback UI that can be rendered for more detail. * the overall validity and a feedback UI that can be rendered for more detail.
*/ */
export default function withValidation<T = undefined>({ description, rules }: IArgs<T>) { export default function withValidation<T = undefined, D = void>({ description, deriveData, rules }: IArgs<T, D>) {
return async function onValidate({ value, focused, allowEmpty = true }: IFieldState): Promise<IValidationResult> { return async function onValidate({ value, focused, allowEmpty = true }: IFieldState): Promise<IValidationResult> {
if (!value && allowEmpty) { if (!value && allowEmpty) {
return { return {
@ -75,6 +80,9 @@ export default function withValidation<T = undefined>({ description, rules }: IA
}; };
} }
const data = { value, allowEmpty };
const derivedData = deriveData ? await deriveData(data) : undefined;
const results = []; const results = [];
let valid = true; let valid = true;
if (rules && rules.length) { if (rules && rules.length) {
@ -87,20 +95,18 @@ export default function withValidation<T = undefined>({ description, rules }: IA
continue; continue;
} }
const data = { value, allowEmpty }; if (rule.skip && rule.skip.call(this, data, derivedData)) {
if (rule.skip && rule.skip.call(this, data)) {
continue; continue;
} }
// We're setting `this` to whichever component holds the validation // We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component. // function. That allows rules to access the state of the component.
const ruleValid = await rule.test.call(this, data); const ruleValid = await rule.test.call(this, data, derivedData);
valid = valid && ruleValid; valid = valid && ruleValid;
if (ruleValid && rule.valid) { if (ruleValid && rule.valid) {
// If the rule's result is valid and has text to show for // If the rule's result is valid and has text to show for
// the valid state, show it. // the valid state, show it.
const text = rule.valid.call(this); const text = rule.valid.call(this, derivedData);
if (!text) { if (!text) {
continue; continue;
} }
@ -112,7 +118,7 @@ export default function withValidation<T = undefined>({ description, rules }: IA
} else if (!ruleValid && rule.invalid) { } else if (!ruleValid && rule.invalid) {
// If the rule's result is invalid and has text to show for // If the rule's result is invalid and has text to show for
// the invalid state, show it. // the invalid state, show it.
const text = rule.invalid.call(this); const text = rule.invalid.call(this, derivedData);
if (!text) { if (!text) {
continue; continue;
} }
@ -153,7 +159,7 @@ export default function withValidation<T = undefined>({ description, rules }: IA
if (description) { if (description) {
// We're setting `this` to whichever component holds the validation // We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component. // function. That allows rules to access the state of the component.
const content = description.call(this); const content = description.call(this, derivedData);
summary = <div className="mx_Validation_description">{content}</div>; summary = <div className="mx_Validation_description">{content}</div>;
} }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,32 +15,53 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {RefObject} from 'react';
import PropTypes from 'prop-types';
import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker"; import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker";
import * as sdk from '../../../index'; import LazyRenderList from "../elements/LazyRenderList";
import {DATA_BY_CATEGORY, IEmoji} from "../../../emoji";
import Emoji from './Emoji';
const OVERFLOW_ROWS = 3; const OVERFLOW_ROWS = 3;
class Category extends React.PureComponent { export type CategoryKey = (keyof typeof DATA_BY_CATEGORY) | "recent";
static propTypes = {
emojis: PropTypes.arrayOf(PropTypes.object).isRequired,
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
onMouseEnter: PropTypes.func.isRequired,
onMouseLeave: PropTypes.func.isRequired,
onClick: PropTypes.func.isRequired,
selectedEmojis: PropTypes.instanceOf(Set),
};
_renderEmojiRow = (rowIndex) => { export interface ICategory {
id: CategoryKey;
name: string;
enabled: boolean;
visible: boolean;
ref: RefObject<HTMLButtonElement>;
}
interface IProps {
id: string;
name: string;
emojis: IEmoji[];
selectedEmojis: Set<string>;
heightBefore: number;
viewportHeight: number;
scrollTop: number;
onClick(emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void;
}
class Category extends React.PureComponent<IProps> {
private renderEmojiRow = (rowIndex: number) => {
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
const Emoji = sdk.getComponent("emojipicker.Emoji");
return (<div key={rowIndex}>{ return (<div key={rowIndex}>{
emojisForRow.map(emoji => emojisForRow.map(emoji => ((
<Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis} <Emoji
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />) key={emoji.hexcode}
emoji={emoji}
selectedEmojis={selectedEmojis}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
)))
}</div>); }</div>);
}; };
@ -52,7 +74,6 @@ class Category extends React.PureComponent {
for (let counter = 0; counter < rows.length; ++counter) { for (let counter = 0; counter < rows.length; ++counter) {
rows[counter] = counter; rows[counter] = counter;
} }
const LazyRenderList = sdk.getComponent('elements.LazyRenderList');
const viewportTop = scrollTop; const viewportTop = scrollTop;
const viewportBottom = viewportTop + viewportHeight; const viewportBottom = viewportTop + viewportHeight;
@ -84,7 +105,7 @@ class Category extends React.PureComponent {
height={localHeight} height={localHeight}
overflowItems={OVERFLOW_ROWS} overflowItems={OVERFLOW_ROWS}
overflowMargin={0} overflowMargin={0}
renderItem={this._renderEmojiRow}> renderItem={this.renderEmojiRow}>
</LazyRenderList> </LazyRenderList>
</section> </section>
); );

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,18 +16,19 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {MenuItem} from "../../structures/ContextMenu"; import {MenuItem} from "../../structures/ContextMenu";
import {IEmoji} from "../../../emoji";
class Emoji extends React.PureComponent { interface IProps {
static propTypes = { emoji: IEmoji;
onClick: PropTypes.func, selectedEmojis?: Set<string>;
onMouseEnter: PropTypes.func, onClick(emoji: IEmoji): void;
onMouseLeave: PropTypes.func, onMouseEnter(emoji: IEmoji): void;
emoji: PropTypes.object.isRequired, onMouseLeave(emoji: IEmoji): void;
selectedEmojis: PropTypes.instanceOf(Set), }
};
class Emoji extends React.PureComponent<IProps> {
render() { render() {
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode); const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,25 +16,43 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as recent from '../../../emojipicker/recent'; import * as recent from '../../../emojipicker/recent';
import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji"; import {DATA_BY_CATEGORY, getEmojiFromUnicode, IEmoji} from "../../../emoji";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import Header from "./Header";
import Search from "./Search";
import Preview from "./Preview";
import QuickReactions from "./QuickReactions";
import Category, {ICategory, CategoryKey} from "./Category";
export const CATEGORY_HEADER_HEIGHT = 22; export const CATEGORY_HEADER_HEIGHT = 22;
export const EMOJI_HEIGHT = 37; export const EMOJI_HEIGHT = 37;
export const EMOJIS_PER_ROW = 8; export const EMOJIS_PER_ROW = 8;
class EmojiPicker extends React.Component { interface IProps {
static propTypes = { selectedEmojis: Set<string>;
onChoose: PropTypes.func.isRequired, showQuickReactions?: boolean;
selectedEmojis: PropTypes.instanceOf(Set), onChoose(unicode: string): boolean;
showQuickReactions: PropTypes.bool, }
};
interface IState {
filter: string;
previewEmoji?: IEmoji;
scrollTop: number;
// initial estimation of height, dialog is hardcoded to 450px height.
// should be enough to never have blank rows of emojis as
// 3 rows of overflow are also rendered. The actual value is updated on scroll.
viewportHeight: number;
}
class EmojiPicker extends React.Component<IProps, IState> {
private readonly recentlyUsed: IEmoji[];
private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>;
private readonly categories: ICategory[];
private bodyRef = React.createRef<HTMLDivElement>();
constructor(props) { constructor(props) {
super(props); super(props);
@ -42,9 +61,6 @@ class EmojiPicker extends React.Component {
filter: "", filter: "",
previewEmoji: null, previewEmoji: null,
scrollTop: 0, scrollTop: 0,
// initial estimation of height, dialog is hardcoded to 450px height.
// should be enough to never have blank rows of emojis as
// 3 rows of overflow are also rendered. The actual value is updated on scroll.
viewportHeight: 280, viewportHeight: 280,
}; };
@ -110,18 +126,9 @@ class EmojiPicker extends React.Component {
visible: false, visible: false,
ref: React.createRef(), ref: React.createRef(),
}]; }];
this.bodyRef = React.createRef();
this.onChangeFilter = this.onChangeFilter.bind(this);
this.onHoverEmoji = this.onHoverEmoji.bind(this);
this.onHoverEmojiEnd = this.onHoverEmojiEnd.bind(this);
this.onClickEmoji = this.onClickEmoji.bind(this);
this.scrollToCategory = this.scrollToCategory.bind(this);
this.updateVisibility = this.updateVisibility.bind(this);
} }
onScroll = () => { private onScroll = () => {
const body = this.bodyRef.current; const body = this.bodyRef.current;
this.setState({ this.setState({
scrollTop: body.scrollTop, scrollTop: body.scrollTop,
@ -130,7 +137,7 @@ class EmojiPicker extends React.Component {
this.updateVisibility(); this.updateVisibility();
}; };
updateVisibility() { private updateVisibility = () => {
const body = this.bodyRef.current; const body = this.bodyRef.current;
const rect = body.getBoundingClientRect(); const rect = body.getBoundingClientRect();
for (const cat of this.categories) { for (const cat of this.categories) {
@ -147,21 +154,21 @@ class EmojiPicker extends React.Component {
// We update this here instead of through React to avoid re-render on scroll. // We update this here instead of through React to avoid re-render on scroll.
if (cat.visible) { if (cat.visible) {
cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible"); cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
cat.ref.current.setAttribute("aria-selected", true); cat.ref.current.setAttribute("aria-selected", "true");
cat.ref.current.setAttribute("tabindex", 0); cat.ref.current.setAttribute("tabindex", "0");
} else { } else {
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible"); cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
cat.ref.current.setAttribute("aria-selected", false); cat.ref.current.setAttribute("aria-selected", "false");
cat.ref.current.setAttribute("tabindex", -1); cat.ref.current.setAttribute("tabindex", "-1");
}
} }
} }
};
scrollToCategory(category) { private scrollToCategory = (category: string) => {
this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView(); this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView();
} };
onChangeFilter(filter) { private onChangeFilter = (filter: string) => {
filter = filter.toLowerCase(); // filter is case insensitive stored lower-case filter = filter.toLowerCase(); // filter is case insensitive stored lower-case
for (const cat of this.categories) { for (const cat of this.categories) {
let emojis; let emojis;
@ -181,27 +188,34 @@ class EmojiPicker extends React.Component {
// Header underlines need to be updated, but updating requires knowing // Header underlines need to be updated, but updating requires knowing
// where the categories are, so we wait for a tick. // where the categories are, so we wait for a tick.
setTimeout(this.updateVisibility, 0); setTimeout(this.updateVisibility, 0);
} };
onHoverEmoji(emoji) { private onEnterFilter = () => {
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");
if (btn) {
btn.click();
}
};
private onHoverEmoji = (emoji: IEmoji) => {
this.setState({ this.setState({
previewEmoji: emoji, previewEmoji: emoji,
}); });
} };
onHoverEmojiEnd(emoji) { private onHoverEmojiEnd = (emoji: IEmoji) => {
this.setState({ this.setState({
previewEmoji: null, previewEmoji: null,
}); });
} };
onClickEmoji(emoji) { private onClickEmoji = (emoji: IEmoji) => {
if (this.props.onChoose(emoji.unicode) !== false) { if (this.props.onChoose(emoji.unicode) !== false) {
recent.add(emoji.unicode); recent.add(emoji.unicode);
} }
} };
_categoryHeightForEmojiCount(count) { private static categoryHeightForEmojiCount(count: number) {
if (count === 0) { if (count === 0) {
return 0; return 0;
} }
@ -209,25 +223,37 @@ class EmojiPicker extends React.Component {
} }
render() { render() {
const Header = sdk.getComponent("emojipicker.Header");
const Search = sdk.getComponent("emojipicker.Search");
const Category = sdk.getComponent("emojipicker.Category");
const Preview = sdk.getComponent("emojipicker.Preview");
const QuickReactions = sdk.getComponent("emojipicker.QuickReactions");
let heightBefore = 0; let heightBefore = 0;
return ( return (
<div className="mx_EmojiPicker"> <div className="mx_EmojiPicker">
<Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} /> <Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
<Search query={this.state.filter} onChange={this.onChangeFilter} /> <Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
<AutoHideScrollbar className="mx_EmojiPicker_body" wrappedRef={e => this.bodyRef.current = e} onScroll={this.onScroll}> <AutoHideScrollbar
className="mx_EmojiPicker_body"
wrappedRef={ref => {
// @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead
this.bodyRef.current = ref
}}
onScroll={this.onScroll}
>
{this.categories.map(category => { {this.categories.map(category => {
const emojis = this.memoizedDataByCategory[category.id]; const emojis = this.memoizedDataByCategory[category.id];
const categoryElement = (<Category key={category.id} id={category.id} name={category.name} const categoryElement = ((
heightBefore={heightBefore} viewportHeight={this.state.viewportHeight} <Category
scrollTop={this.state.scrollTop} emojis={emojis} onClick={this.onClickEmoji} key={category.id}
onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd} id={category.id}
selectedEmojis={this.props.selectedEmojis} />); name={category.name}
const height = this._categoryHeightForEmojiCount(emojis.length); heightBefore={heightBefore}
viewportHeight={this.state.viewportHeight}
scrollTop={this.state.scrollTop}
emojis={emojis}
onClick={this.onClickEmoji}
onMouseEnter={this.onHoverEmoji}
onMouseLeave={this.onHoverEmojiEnd}
selectedEmojis={this.props.selectedEmojis}
/>
));
const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length);
heightBefore += height; heightBefore += height;
return categoryElement; return categoryElement;
})} })}

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,19 +16,19 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from "classnames"; import classNames from "classnames";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import {CategoryKey, ICategory} from "./Category";
class Header extends React.PureComponent { interface IProps {
static propTypes = { categories: ICategory[];
categories: PropTypes.arrayOf(PropTypes.object).isRequired, onAnchorClick(id: CategoryKey): void
onAnchorClick: PropTypes.func.isRequired, }
};
findNearestEnabled(index, delta) { class Header extends React.PureComponent<IProps> {
private findNearestEnabled(index: number, delta: number) {
index += this.props.categories.length; index += this.props.categories.length;
const cats = [...this.props.categories, ...this.props.categories, ...this.props.categories]; const cats = [...this.props.categories, ...this.props.categories, ...this.props.categories];
@ -37,12 +38,12 @@ class Header extends React.PureComponent {
} }
} }
changeCategoryRelative(delta) { private changeCategoryRelative(delta: number) {
const current = this.props.categories.findIndex(c => c.visible); const current = this.props.categories.findIndex(c => c.visible);
this.changeCategoryAbsolute(current + delta, delta); this.changeCategoryAbsolute(current + delta, delta);
} }
changeCategoryAbsolute(index, delta=1) { private changeCategoryAbsolute(index: number, delta=1) {
const category = this.props.categories[this.findNearestEnabled(index, delta)]; const category = this.props.categories[this.findNearestEnabled(index, delta)];
if (category) { if (category) {
this.props.onAnchorClick(category.id); this.props.onAnchorClick(category.id);
@ -52,7 +53,7 @@ class Header extends React.PureComponent {
// Implements ARIA Tabs with Automatic Activation pattern // Implements ARIA Tabs with Automatic Activation pattern
// https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html // https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
onKeyDown = (ev) => { private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true; let handled = true;
switch (ev.key) { switch (ev.key) {
case Key.ARROW_LEFT: case Key.ARROW_LEFT:
@ -80,7 +81,12 @@ class Header extends React.PureComponent {
render() { render() {
return ( return (
<nav className="mx_EmojiPicker_header" role="tablist" aria-label={_t("Categories")} onKeyDown={this.onKeyDown}> <nav
className="mx_EmojiPicker_header"
role="tablist"
aria-label={_t("Categories")}
onKeyDown={this.onKeyDown}
>
{this.props.categories.map(category => { {this.props.categories.map(category => {
const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, { const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, {
mx_EmojiPicker_anchor_visible: category.visible, mx_EmojiPicker_anchor_visible: category.visible,

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,19 +16,21 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
class Preview extends React.PureComponent { import {IEmoji} from "../../../emoji";
static propTypes = {
emoji: PropTypes.object,
};
interface IProps {
emoji: IEmoji;
}
class Preview extends React.PureComponent<IProps> {
render() { render() {
const { const {
unicode = "", unicode = "",
annotation = "", annotation = "",
shortcodes: [shortcode = ""], shortcodes: [shortcode = ""],
} = this.props.emoji || {}; } = this.props.emoji || {};
return ( return (
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview"> <div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
<div className="mx_EmojiPicker_preview_emoji"> <div className="mx_EmojiPicker_preview_emoji">

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,11 +16,10 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {getEmojiFromUnicode} from "../../../emoji"; import {getEmojiFromUnicode, IEmoji} from "../../../emoji";
import Emoji from "./Emoji";
// We use the variation-selector Heart in Quick Reactions for some reason // We use the variation-selector Heart in Quick Reactions for some reason
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => { const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
@ -30,36 +30,36 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
return data; return data;
}); });
class QuickReactions extends React.Component { interface IProps {
static propTypes = { selectedEmojis?: Set<string>;
onClick: PropTypes.func.isRequired, onClick(emoji: IEmoji): void;
selectedEmojis: PropTypes.instanceOf(Set), }
};
interface IState {
hover?: IEmoji;
}
class QuickReactions extends React.Component<IProps, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
hover: null, hover: null,
}; };
this.onMouseEnter = this.onMouseEnter.bind(this);
this.onMouseLeave = this.onMouseLeave.bind(this);
} }
onMouseEnter(emoji) { private onMouseEnter = (emoji: IEmoji) => {
this.setState({ this.setState({
hover: emoji, hover: emoji,
}); });
} };
onMouseLeave() { private onMouseLeave = () => {
this.setState({ this.setState({
hover: null, hover: null,
}); });
} };
render() { render() {
const Emoji = sdk.getComponent("emojipicker.Emoji");
return ( return (
<section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category"> <section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category">
<h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label"> <h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label">
@ -72,10 +72,16 @@ class QuickReactions extends React.Component {
} }
</h2> </h2>
<ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}> <ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
{QUICK_REACTIONS.map(emoji => <Emoji {QUICK_REACTIONS.map(emoji => ((
key={emoji.hexcode} emoji={emoji} onClick={this.props.onClick} <Emoji
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave} key={emoji.hexcode}
selectedEmojis={this.props.selectedEmojis} />)} emoji={emoji}
onClick={this.props.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
selectedEmojis={this.props.selectedEmojis}
/>
)))}
</ul> </ul>
</section> </section>
); );

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,26 +16,29 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from "prop-types"; import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import EmojiPicker from "./EmojiPicker"; import EmojiPicker from "./EmojiPicker";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
class ReactionPicker extends React.Component { interface IProps {
static propTypes = { mxEvent: MatrixEvent;
mxEvent: PropTypes.object.isRequired, reactions: any; // TODO type this once js-sdk is more typescripted
onFinished: PropTypes.func.isRequired, onFinished(): void;
reactions: PropTypes.object, }
};
interface IState {
selectedEmojis: Set<string>;
}
class ReactionPicker extends React.Component<IProps, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
selectedEmojis: new Set(Object.keys(this.getReactions())), selectedEmojis: new Set(Object.keys(this.getReactions())),
}; };
this.onChoose = this.onChoose.bind(this);
this.onReactionsChange = this.onReactionsChange.bind(this);
this.addListeners(); this.addListeners();
} }
@ -45,7 +49,7 @@ class ReactionPicker extends React.Component {
} }
} }
addListeners() { private addListeners() {
if (this.props.reactions) { if (this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange); this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange); this.props.reactions.on("Relations.remove", this.onReactionsChange);
@ -55,22 +59,13 @@ class ReactionPicker extends React.Component {
componentWillUnmount() { componentWillUnmount() {
if (this.props.reactions) { if (this.props.reactions) {
this.props.reactions.removeListener( this.props.reactions.removeListener("Relations.add", this.onReactionsChange);
"Relations.add", this.props.reactions.removeListener("Relations.remove", this.onReactionsChange);
this.onReactionsChange, this.props.reactions.removeListener("Relations.redaction", this.onReactionsChange);
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
} }
} }
getReactions() { private getReactions() {
if (!this.props.reactions) { if (!this.props.reactions) {
return {}; return {};
} }
@ -81,13 +76,13 @@ class ReactionPicker extends React.Component {
.map(event => [event.getRelation().key, event.getId()])); .map(event => [event.getRelation().key, event.getId()]));
} }
onReactionsChange() { private onReactionsChange = () => {
this.setState({ this.setState({
selectedEmojis: new Set(Object.keys(this.getReactions())), selectedEmojis: new Set(Object.keys(this.getReactions())),
}); });
} };
onChoose(reaction) { onChoose = (reaction: string) => {
this.componentWillUnmount(); this.componentWillUnmount();
this.props.onFinished(); this.props.onFinished();
const myReactions = this.getReactions(); const myReactions = this.getReactions();
@ -109,7 +104,7 @@ class ReactionPicker extends React.Component {
dis.dispatch({action: "message_sent"}); dis.dispatch({action: "message_sent"});
return true; return true;
} }
} };
render() { render() {
return <EmojiPicker return <EmojiPicker

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 Tulir Asokan <tulir@maunium.net> Copyright 2019 Tulir Asokan <tulir@maunium.net>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,32 +16,41 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {Key} from "../../../Keyboard";
class Search extends React.PureComponent { interface IProps {
static propTypes = { query: string;
query: PropTypes.string.isRequired, onChange(value: string): void;
onChange: PropTypes.func.isRequired, onEnter(): void;
}; }
constructor(props) { class Search extends React.PureComponent<IProps> {
super(props); private inputRef = React.createRef<HTMLInputElement>();
this.inputRef = React.createRef();
}
componentDidMount() { componentDidMount() {
// For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout // For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout
setTimeout(() => this.inputRef.current.focus(), 0); setTimeout(() => this.inputRef.current.focus(), 0);
} }
private onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === Key.ENTER) {
this.props.onEnter();
ev.stopPropagation();
ev.preventDefault();
}
};
render() { render() {
let rightButton; let rightButton;
if (this.props.query) { if (this.props.query) {
rightButton = ( rightButton = (
<button onClick={() => this.props.onChange("")} <button
onClick={() => this.props.onChange("")}
className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear" className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
title={_t("Cancel search")} /> title={_t("Cancel search")}
/>
); );
} else { } else {
rightButton = <span className="mx_EmojiPicker_search_icon" />; rightButton = <span className="mx_EmojiPicker_search_icon" />;
@ -48,8 +58,15 @@ class Search extends React.PureComponent {
return ( return (
<div className="mx_EmojiPicker_search"> <div className="mx_EmojiPicker_search">
<input autoFocus type="text" placeholder="Search" value={this.props.query} <input
onChange={ev => this.props.onChange(ev.target.value)} ref={this.inputRef} /> autoFocus
type="text"
placeholder="Search"
value={this.props.query}
onChange={ev => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown}
ref={this.inputRef}
/>
{rightButton} {rightButton}
</div> </div>
); );

View file

@ -25,10 +25,8 @@ export default class EncryptionEvent extends React.Component {
let body; let body;
let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon"; let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
if ( const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId());
mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId())
) {
body = <div> body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div> <div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
<div className="mx_cryptoEvent_subtitle"> <div className="mx_cryptoEvent_subtitle">
@ -38,6 +36,13 @@ export default class EncryptionEvent extends React.Component {
)} )}
</div> </div>
</div>; </div>;
} else if (isRoomEncrypted) {
body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
<div className="mx_cryptoEvent_subtitle">
{_t("Ignored attempt to disable encryption")}
</div>
</div>;
} else { } else {
body = <div> body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div> <div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>

View file

@ -0,0 +1,76 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
interface IProps {
mxEvent: MatrixEvent;
}
export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
constructor(props) {
super(props);
}
render() {
const url = this.props.mxEvent.getContent()['url'];
const prevUrl = this.props.mxEvent.getPrevContent()['url'];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
let joinCopy = _t('Join the conference at the top of this room');
if (!WidgetStore.instance.isPinned(this.props.mxEvent.getStateKey())) {
joinCopy = _t('Join the conference from the room information card on the right');
}
if (!url) {
// removed
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference ended by %(senderName)s', {senderName})}
</div>
</div>
);
} else if (prevUrl) {
// modified
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference updated by %(senderName)s', {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
} else {
// assume added
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t("Video conference started by %(senderName)s", {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
}
}
}

View file

@ -401,7 +401,8 @@ export default class TextualBody extends React.Component {
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent(); const content = mxEvent.getContent();
const stripReply = ReplyThread.getParentEventId(mxEvent); // only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && ReplyThread.getParentEventId(mxEvent);
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, { let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'), disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
// Part of Replies fallback support // Part of Replies fallback support

View file

@ -31,6 +31,7 @@ interface IProps {
className?: string; className?: string;
withoutScrollContainer?: boolean; withoutScrollContainer?: boolean;
previousPhase?: RightPanelPhases; previousPhase?: RightPanelPhases;
closeLabel?: string;
onClose?(): void; onClose?(): void;
} }
@ -47,6 +48,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
}; };
const BaseCard: React.FC<IProps> = ({ const BaseCard: React.FC<IProps> = ({
closeLabel,
onClose, onClose,
className, className,
header, header,
@ -68,7 +70,11 @@ const BaseCard: React.FC<IProps> = ({
let closeButton; let closeButton;
if (onClose) { if (onClose) {
closeButton = <AccessibleButton className="mx_BaseCard_close" onClick={onClose} title={_t("Close")} />; closeButton = <AccessibleButton
className="mx_BaseCard_close"
onClick={onClose}
title={closeLabel || _t("Close")}
/>;
} }
if (!withoutScrollContainer) { if (!withoutScrollContainer) {

View file

@ -27,6 +27,9 @@ import * as sdk from "../../../index";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
// cancellation codes which constitute a key mismatch // cancellation codes which constitute a key mismatch
const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"]; const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
@ -42,7 +45,14 @@ interface IProps {
} }
const EncryptionPanel: React.FC<IProps> = (props: IProps) => { const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
const {verificationRequest, verificationRequestPromise, member, onClose, layout, isRoomEncrypted} = props; const {
verificationRequest,
verificationRequestPromise,
member,
onClose,
layout,
isRoomEncrypted,
} = props;
const [request, setRequest] = useState(verificationRequest); const [request, setRequest] = useState(verificationRequest);
// state to show a spinner immediately after clicking "start verification", // state to show a spinner immediately after clicking "start verification",
// before we have a request // before we have a request
@ -95,22 +105,6 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
}, [onClose, request]); }, [onClose, request]);
useEventEmitter(request, "change", changeHandler); useEventEmitter(request, "change", changeHandler);
const onCancel = useCallback(function() {
if (request) {
request.cancel();
}
}, [request]);
let cancelButton: JSX.Element;
if (layout !== "dialog" && request && request.pending) {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
cancelButton = (<AccessibleButton
className="mx_EncryptionPanel_cancel"
onClick={onCancel}
title={_t('Cancel')}
></AccessibleButton>);
}
const onStartVerification = useCallback(async () => { const onStartVerification = useCallback(async () => {
setRequesting(true); setRequesting(true);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -118,7 +112,13 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId); const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId);
setRequest(verificationRequest_); setRequest(verificationRequest_);
setPhase(verificationRequest_.phase); setPhase(verificationRequest_.phase);
}, [member.userId]); // Notify the RightPanelStore about this
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.EncryptionPanel,
refireParams: { member, verificationRequest: verificationRequest_ },
});
}, [member]);
const requested = const requested =
(!request && isRequesting) || (!request && isRequesting) ||
@ -128,8 +128,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
member.userId === MatrixClientPeg.get().getUserId(); member.userId === MatrixClientPeg.get().getUserId();
if (!request || requested) { if (!request || requested) {
const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe); const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe);
return (<React.Fragment> return (
{cancelButton}
<EncryptionInfo <EncryptionInfo
isRoomEncrypted={isRoomEncrypted} isRoomEncrypted={isRoomEncrypted}
onStartVerification={onStartVerification} onStartVerification={onStartVerification}
@ -138,10 +137,9 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
waitingForOtherParty={requested && initiatedByMe} waitingForOtherParty={requested && initiatedByMe}
waitingForNetwork={requested && !initiatedByMe} waitingForNetwork={requested && !initiatedByMe}
inDialog={layout === "dialog"} /> inDialog={layout === "dialog"} />
</React.Fragment>); );
} else { } else {
return (<React.Fragment> return (
{cancelButton}
<VerificationPanel <VerificationPanel
isRoomEncrypted={isRoomEncrypted} isRoomEncrypted={isRoomEncrypted}
layout={layout} layout={layout}
@ -152,7 +150,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
inDialog={layout === "dialog"} inDialog={layout === "dialog"}
phase={phase} phase={phase}
/> />
</React.Fragment>); );
} }
}; };

View file

@ -17,20 +17,22 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useCallback, useMemo, useState, useEffect, useContext} from 'react'; import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import {Group, RoomMember, User, Room} from 'matrix-js-sdk'; import {MatrixClient} from 'matrix-js-sdk/src/client';
import {RoomMember} from 'matrix-js-sdk/src/models/room-member';
import {User} from 'matrix-js-sdk/src/models/user';
import {Room} from 'matrix-js-sdk/src/models/room';
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as sdk from '../../../index'; import {_t} from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import createRoom, {privateShouldBeEncrypted} from '../../../createRoom'; import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {EventTimeline} from "matrix-js-sdk";
import RoomViewStore from "../../../stores/RoomViewStore"; import RoomViewStore from "../../../stores/RoomViewStore";
import MultiInviter from "../../../utils/MultiInviter"; import MultiInviter from "../../../utils/MultiInviter";
import GroupStore from "../../../stores/GroupStore"; import GroupStore from "../../../stores/GroupStore";
@ -41,13 +43,31 @@ import {textualPowerLevel} from '../../../Roles';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel"; import EncryptionPanel from "./EncryptionPanel";
import { useAsyncMemo } from '../../../hooks/useAsyncMemo'; import {useAsyncMemo} from '../../../hooks/useAsyncMemo';
import { verifyUser, legacyVerifyUser, verifyDevice } from '../../../verification'; import {legacyVerifyUser, verifyDevice, verifyUser} from '../../../verification';
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {useIsEncrypted} from "../../../hooks/useIsEncrypted"; import {useIsEncrypted} from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard"; import BaseCard from "./BaseCard";
import {E2EStatus} from "../../../utils/ShieldUtils";
import ImageView from "../elements/ImageView";
import Spinner from "../elements/Spinner";
import IconButton from "../elements/IconButton";
import PowerSelector from "../elements/PowerSelector";
import MemberAvatar from "../avatars/MemberAvatar";
import PresenceLabel from "../rooms/PresenceLabel";
import ShareDialog from "../dialogs/ShareDialog";
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
import InfoDialog from "../dialogs/InfoDialog";
const _disambiguateDevices = (devices) => { interface IDevice {
deviceId: string;
ambiguous?: boolean;
getDisplayName(): string;
}
const disambiguateDevices = (devices: IDevice[]) => {
const names = Object.create(null); const names = Object.create(null);
for (let i = 0; i < devices.length; i++) { for (let i = 0; i < devices.length; i++) {
const name = devices[i].getDisplayName(); const name = devices[i].getDisplayName();
@ -64,11 +84,11 @@ const _disambiguateDevices = (devices) => {
} }
}; };
export const getE2EStatus = (cli, userId, devices) => { export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice[]): E2EStatus => {
const isMe = userId === cli.getUserId(); const isMe = userId === cli.getUserId();
const userTrust = cli.checkUserTrust(userId); const userTrust = cli.checkUserTrust(userId);
if (!userTrust.isCrossSigningVerified()) { if (!userTrust.isCrossSigningVerified()) {
return userTrust.wasCrossSigningVerified() ? "warning" : "normal"; return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal;
} }
const anyDeviceUnverified = devices.some(device => { const anyDeviceUnverified = devices.some(device => {
@ -81,10 +101,10 @@ export const getE2EStatus = (cli, userId, devices) => {
const deviceTrust = cli.checkDeviceTrust(userId, deviceId); const deviceTrust = cli.checkDeviceTrust(userId, deviceId);
return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified(); return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified();
}); });
return anyDeviceUnverified ? "warning" : "verified"; return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified;
}; };
async function openDMForUser(matrixClient, userId) { async function openDMForUser(matrixClient: MatrixClient, userId: string) {
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId); const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => { const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
const room = matrixClient.getRoom(roomId); const room = matrixClient.getRoom(roomId);
@ -107,6 +127,7 @@ async function openDMForUser(matrixClient, userId) {
const createRoomOptions = { const createRoomOptions = {
dmUserId: userId, dmUserId: userId,
encryption: undefined,
}; };
if (privateShouldBeEncrypted()) { if (privateShouldBeEncrypted()) {
@ -122,10 +143,12 @@ async function openDMForUser(matrixClient, userId) {
} }
} }
createRoom(createRoomOptions); return createRoom(createRoomOptions);
} }
function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) { type SetUpdating = (updating: boolean) => void;
function useHasCrossSigningKeys(cli: MatrixClient, member: RoomMember, canVerify: boolean, setUpdating: SetUpdating) {
return useAsyncMemo(async () => { return useAsyncMemo(async () => {
if (!canVerify) { if (!canVerify) {
return undefined; return undefined;
@ -142,7 +165,7 @@ function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) {
}, [cli, member, canVerify], undefined); }, [cli, member, canVerify], undefined);
} }
function DeviceItem({userId, device}) { function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const isMe = userId === cli.getUserId(); const isMe = userId === cli.getUserId();
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId); const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
@ -198,8 +221,7 @@ function DeviceItem({userId, device}) {
} }
} }
function DevicesSection({devices, userId, loading}) { function DevicesSection({devices, userId, loading}: {devices: IDevice[], userId: string, loading: boolean}) {
const Spinner = sdk.getComponent("elements.Spinner");
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const userTrust = cli.checkUserTrust(userId); const userTrust = cli.checkUserTrust(userId);
@ -210,7 +232,7 @@ function DevicesSection({devices, userId, loading}) {
return <Spinner />; return <Spinner />;
} }
if (devices === null) { if (devices === null) {
return _t("Unable to load session list"); return <>{_t("Unable to load session list")}</>;
} }
const isMe = userId === cli.getUserId(); const isMe = userId === cli.getUserId();
const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId)); const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId));
@ -285,7 +307,11 @@ function DevicesSection({devices, userId, loading}) {
); );
} }
const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => { const UserOptionsSection: React.FC<{
member: RoomMember;
isIgnored: boolean;
canInvite: boolean;
}> = ({member, isIgnored, canInvite}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
let ignoreButton = null; let ignoreButton = null;
@ -296,7 +322,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
const isMe = member.userId === cli.getUserId(); const isMe = member.userId === cli.getUserId();
const onShareUserClick = () => { const onShareUserClick = () => {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room member dialog', '', ShareDialog, { Modal.createTrackedDialog('share room member dialog', '', ShareDialog, {
target: member, target: member,
}); });
@ -318,7 +343,10 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
}; };
ignoreButton = ( ignoreButton = (
<AccessibleButton onClick={onIgnoreToggle} className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}> <AccessibleButton
onClick={onIgnoreToggle}
className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}
>
{ isIgnored ? _t("Unignore") : _t("Ignore") } { isIgnored ? _t("Unignore") : _t("Ignore") }
</AccessibleButton> </AccessibleButton>
); );
@ -341,11 +369,14 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
}); });
}; };
const room = cli.getRoom(member.roomId);
if (room?.getEventReadUpTo(member.userId)) {
readReceiptButton = ( readReceiptButton = (
<AccessibleButton onClick={onReadReceiptButton} className="mx_UserInfo_field"> <AccessibleButton onClick={onReadReceiptButton} className="mx_UserInfo_field">
{ _t('Jump to read receipt') } { _t('Jump to read receipt') }
</AccessibleButton> </AccessibleButton>
); );
}
insertPillButton = ( insertPillButton = (
<AccessibleButton onClick={onInsertPillButton} className={"mx_UserInfo_field"}> <AccessibleButton onClick={onInsertPillButton} className={"mx_UserInfo_field"}>
@ -367,7 +398,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
} }
}); });
} catch (err) { } catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t('Failed to invite'), title: _t('Failed to invite'),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
@ -413,8 +443,7 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
); );
}; };
const _warnSelfDemote = async () => { const warnSelfDemote = async () => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
title: _t("Demote yourself?"), title: _t("Demote yourself?"),
description: description:
@ -430,7 +459,7 @@ const _warnSelfDemote = async () => {
return confirmed; return confirmed;
}; };
const GenericAdminToolsContainer = ({children}) => { const GenericAdminToolsContainer: React.FC<{}> = ({children}) => {
return ( return (
<div className="mx_UserInfo_container"> <div className="mx_UserInfo_container">
<h3>{ _t("Admin Tools") }</h3> <h3>{ _t("Admin Tools") }</h3>
@ -441,7 +470,20 @@ const GenericAdminToolsContainer = ({children}) => {
); );
}; };
const _isMuted = (member, powerLevelContent) => { interface IPowerLevelsContent {
events?: Record<string, number>;
// eslint-disable-next-line camelcase
users_default?: number;
// eslint-disable-next-line camelcase
events_default?: number;
// eslint-disable-next-line camelcase
state_default?: number;
ban?: number;
kick?: number;
redact?: number;
}
const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => {
if (!powerLevelContent || !member) return false; if (!powerLevelContent || !member) return false;
const levelToSend = ( const levelToSend = (
@ -451,8 +493,8 @@ const _isMuted = (member, powerLevelContent) => {
return member.powerLevel < levelToSend; return member.powerLevel < levelToSend;
}; };
export const useRoomPowerLevels = (cli, room) => { export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
const [powerLevels, setPowerLevels] = useState({}); const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({});
const update = useCallback(() => { const update = useCallback(() => {
if (!room) { if (!room) {
@ -479,14 +521,19 @@ export const useRoomPowerLevels = (cli, room) => {
return powerLevels; return powerLevels;
}; };
const RoomKickButton = ({member, startUpdating, stopUpdating}) => { interface IBaseProps {
member: RoomMember;
startUpdating(): void;
stopUpdating(): void;
}
const RoomKickButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
// check if user can be kicked/disinvited // check if user can be kicked/disinvited
if (member.membership !== "invite" && member.membership !== "join") return null; if (member.membership !== "invite" && member.membership !== "join") return null;
const onKick = async () => { const onKick = async () => {
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
const {finished} = Modal.createTrackedDialog( const {finished} = Modal.createTrackedDialog(
'Confirm User Action Dialog', 'Confirm User Action Dialog',
'onKick', 'onKick',
@ -509,7 +556,6 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Kick success"); console.log("Kick success");
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Kick error: " + err); console.error("Kick error: " + err);
Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, { Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, {
title: _t("Failed to kick"), title: _t("Failed to kick"),
@ -526,7 +572,7 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
</AccessibleButton>; </AccessibleButton>;
}; };
const RedactMessagesButton = ({member}) => { const RedactMessagesButton: React.FC<IBaseProps> = ({member}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const onRedactAllMessages = async () => { const onRedactAllMessages = async () => {
@ -554,7 +600,6 @@ const RedactMessagesButton = ({member}) => {
const user = member.name; const user = member.name;
if (count === 0) { if (count === 0) {
const InfoDialog = sdk.getComponent("dialogs.InfoDialog");
Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, { Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, {
title: _t("No recent messages by %(user)s found", {user}), title: _t("No recent messages by %(user)s found", {user}),
description: description:
@ -563,14 +608,14 @@ const RedactMessagesButton = ({member}) => {
</div>, </div>,
}); });
} else { } else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, { const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, {
title: _t("Remove recent messages by %(user)s", {user}), title: _t("Remove recent messages by %(user)s", {user}),
description: description:
<div> <div>
<p>{ _t("You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?", {count, user}) }</p> <p>{ _t("You are about to remove %(count)s messages by %(user)s. " +
<p>{ _t("For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.") }</p> "This cannot be undone. Do you wish to continue?", {count, user}) }</p>
<p>{ _t("For a large amount of messages, this might take some time. " +
"Please don't refresh your client in the meantime.") }</p>
</div>, </div>,
button: _t("Remove %(count)s messages", {count}), button: _t("Remove %(count)s messages", {count}),
}); });
@ -603,11 +648,10 @@ const RedactMessagesButton = ({member}) => {
</AccessibleButton>; </AccessibleButton>;
}; };
const BanToggleButton = ({member, startUpdating, stopUpdating}) => { const BanToggleButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const onBanOrUnban = async () => { const onBanOrUnban = async () => {
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
const {finished} = Modal.createTrackedDialog( const {finished} = Modal.createTrackedDialog(
'Confirm User Action Dialog', 'Confirm User Action Dialog',
'onBanOrUnban', 'onBanOrUnban',
@ -636,7 +680,6 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Ban success"); console.log("Ban success");
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Ban error: " + err); console.error("Ban error: " + err);
Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, { Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, {
title: _t("Error"), title: _t("Error"),
@ -661,22 +704,26 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
</AccessibleButton>; </AccessibleButton>;
}; };
const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdating}) => { interface IBaseRoomProps extends IBaseProps {
room: Room;
powerLevels: IPowerLevelsContent;
}
const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels, startUpdating, stopUpdating}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
// Don't show the mute/unmute option if the user is not in the room // Don't show the mute/unmute option if the user is not in the room
if (member.membership !== "join") return null; if (member.membership !== "join") return null;
const isMuted = _isMuted(member, powerLevels); const muted = isMuted(member, powerLevels);
const onMuteToggle = async () => { const onMuteToggle = async () => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const roomId = member.roomId; const roomId = member.roomId;
const target = member.userId; const target = member.userId;
// if muting self, warn as it may be irreversible // if muting self, warn as it may be irreversible
if (target === cli.getUserId()) { if (target === cli.getUserId()) {
try { try {
if (!(await _warnSelfDemote())) return; if (!(await warnSelfDemote())) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
return; return;
@ -692,7 +739,7 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
powerLevels.events_default powerLevels.events_default
); );
let level; let level;
if (isMuted) { // unmute if (muted) { // unmute
level = levelToSend; level = levelToSend;
} else { // mute } else { // mute
level = levelToSend - 1; level = levelToSend - 1;
@ -718,16 +765,23 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
}; };
const classes = classNames("mx_UserInfo_field", { const classes = classNames("mx_UserInfo_field", {
mx_UserInfo_destructive: !isMuted, mx_UserInfo_destructive: !muted,
}); });
const muteLabel = isMuted ? _t("Unmute") : _t("Mute"); const muteLabel = muted ? _t("Unmute") : _t("Mute");
return <AccessibleButton className={classes} onClick={onMuteToggle}> return <AccessibleButton className={classes} onClick={onMuteToggle}>
{ muteLabel } { muteLabel }
</AccessibleButton>; </AccessibleButton>;
}; };
const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpdating, powerLevels}) => { const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
room,
children,
member,
startUpdating,
stopUpdating,
powerLevels,
}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
let kickButton; let kickButton;
let banButton; let banButton;
@ -786,7 +840,18 @@ const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpd
return <div />; return <div />;
}; };
const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, stopUpdating}) => { interface GroupMember {
userId: string;
displayname?: string; // XXX: GroupMember objects are inconsistent :((
avatarUrl?: string;
}
const GroupAdminToolsSection: React.FC<{
groupId: string;
groupMember: GroupMember;
startUpdating(): void;
stopUpdating(): void;
}> = ({children, groupId, groupMember, startUpdating, stopUpdating}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const [isPrivileged, setIsPrivileged] = useState(false); const [isPrivileged, setIsPrivileged] = useState(false);
@ -814,8 +879,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
}, [groupId, groupMember.userId]); }, [groupId, groupMember.userId]);
if (isPrivileged) { if (isPrivileged) {
const _onKick = async () => { const onKick = async () => {
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
const {finished} = Modal.createDialog(ConfirmUserActionDialog, { const {finished} = Modal.createDialog(ConfirmUserActionDialog, {
matrixClient: cli, matrixClient: cli,
groupMember, groupMember,
@ -836,7 +900,6 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
member: null, member: null,
}); });
}).catch((e) => { }).catch((e) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, { Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, {
title: _t('Error'), title: _t('Error'),
description: isInvited ? description: isInvited ?
@ -850,7 +913,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
}; };
const kickButton = ( const kickButton = (
<AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={_onKick}> <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onKick}>
{ isInvited ? _t('Disinvite') : _t('Remove from community') } { isInvited ? _t('Disinvite') : _t('Remove from community') }
</AccessibleButton> </AccessibleButton>
); );
@ -870,13 +933,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
return <div />; return <div />;
}; };
const GroupMember = PropTypes.shape({ const useIsSynapseAdmin = (cli: MatrixClient) => {
userId: PropTypes.string.isRequired,
displayname: PropTypes.string, // XXX: GroupMember objects are inconsistent :((
avatarUrl: PropTypes.string,
});
const useIsSynapseAdmin = (cli) => {
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => { useEffect(() => {
cli.isSynapseAdministrator().then((isAdmin) => { cli.isSynapseAdministrator().then((isAdmin) => {
@ -888,14 +945,20 @@ const useIsSynapseAdmin = (cli) => {
return isAdmin; return isAdmin;
}; };
const useHomeserverSupportsCrossSigning = (cli) => { const useHomeserverSupportsCrossSigning = (cli: MatrixClient) => {
return useAsyncMemo(async () => { return useAsyncMemo<boolean>(async () => {
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"); return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
}, [cli], false); }, [cli], false);
}; };
function useRoomPermissions(cli, room, user) { interface IRoomPermissions {
const [roomPermissions, setRoomPermissions] = useState({ modifyLevelMax: number;
canEdit: boolean;
canInvite: boolean;
}
function useRoomPermissions(cli: MatrixClient, room: Room, user: User): IRoomPermissions {
const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({
// modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
modifyLevelMax: -1, modifyLevelMax: -1,
canEdit: false, canEdit: false,
@ -940,7 +1003,7 @@ function useRoomPermissions(cli, room, user) {
updateRoomPermissions(); updateRoomPermissions();
return () => { return () => {
setRoomPermissions({ setRoomPermissions({
maximalPowerLevel: -1, modifyLevelMax: -1,
canEdit: false, canEdit: false,
canInvite: false, canInvite: false,
}); });
@ -950,14 +1013,18 @@ function useRoomPermissions(cli, room, user) {
return roomPermissions; return roomPermissions;
} }
const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => { const PowerLevelSection: React.FC<{
user: User;
room: Room;
roomPermissions: IRoomPermissions;
powerLevels: IPowerLevelsContent;
}> = ({user, room, roomPermissions, powerLevels}) => {
const [isEditing, setEditing] = useState(false); const [isEditing, setEditing] = useState(false);
if (isEditing) { if (isEditing) {
return (<PowerLevelEditor return (<PowerLevelEditor
user={user} room={room} roomPermissions={roomPermissions} user={user} room={room} roomPermissions={roomPermissions}
onFinished={() => setEditing(false)} />); onFinished={() => setEditing(false)} />);
} else { } else {
const IconButton = sdk.getComponent('elements.IconButton');
const powerLevelUsersDefault = powerLevels.users_default || 0; const powerLevelUsersDefault = powerLevels.users_default || 0;
const powerLevel = parseInt(user.powerLevel, 10); const powerLevel = parseInt(user.powerLevel, 10);
const modifyButton = roomPermissions.canEdit ? const modifyButton = roomPermissions.canEdit ?
@ -975,7 +1042,12 @@ const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => {
} }
}; };
const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => { const PowerLevelEditor: React.FC<{
user: User;
room: Room;
roomPermissions: IRoomPermissions;
onFinished(): void;
}> = ({user, room, roomPermissions, onFinished}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@ -994,7 +1066,6 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Power change success"); console.log("Power change success");
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change power level " + err); console.error("Failed to change power level " + err);
Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, { Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, {
title: _t("Error"), title: _t("Error"),
@ -1025,12 +1096,10 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
} }
const myUserId = cli.getUserId(); const myUserId = cli.getUserId();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse. // If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
if (myUserId === target) { if (myUserId === target) {
try { try {
if (!(await _warnSelfDemote())) return; if (!(await warnSelfDemote())) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
} }
@ -1039,7 +1108,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
} }
const myPower = powerLevelEvent.getContent().users[myUserId]; const myPower = powerLevelEvent.getContent().users[myUserId];
if (parseInt(myPower) === parseInt(powerLevel)) { if (parseInt(myPower) === powerLevel) {
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
title: _t("Warning!"), title: _t("Warning!"),
description: description:
@ -1062,12 +1131,9 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0; const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
const IconButton = sdk.getComponent('elements.IconButton');
const Spinner = sdk.getComponent("elements.Spinner");
const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> : const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> :
<IconButton icon="check" onClick={changePowerLevel} />; <IconButton icon="check" onClick={changePowerLevel} />;
const PowerSelector = sdk.getComponent('elements.PowerSelector');
return ( return (
<div className="mx_UserInfo_profileField"> <div className="mx_UserInfo_profileField">
<PowerSelector <PowerSelector
@ -1083,7 +1149,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
); );
}; };
export const useDevices = (userId) => { export const useDevices = (userId: string) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
// undefined means yet to be loaded, null means failed to load, otherwise list of devices // undefined means yet to be loaded, null means failed to load, otherwise list of devices
@ -1094,7 +1160,7 @@ export const useDevices = (userId) => {
let cancelled = false; let cancelled = false;
async function _downloadDeviceList() { async function downloadDeviceList() {
try { try {
await cli.downloadKeys([userId], true); await cli.downloadKeys([userId], true);
const devices = cli.getStoredDevicesForUser(userId); const devices = cli.getStoredDevicesForUser(userId);
@ -1104,13 +1170,13 @@ export const useDevices = (userId) => {
return; return;
} }
_disambiguateDevices(devices); disambiguateDevices(devices);
setDevices(devices); setDevices(devices);
} catch (err) { } catch (err) {
setDevices(null); setDevices(null);
} }
} }
_downloadDeviceList(); downloadDeviceList();
// Handle being unmounted // Handle being unmounted
return () => { return () => {
@ -1153,7 +1219,13 @@ export const useDevices = (userId) => {
return devices; return devices;
}; };
const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { const BasicUserInfo: React.FC<{
room: Room;
member: User | RoomMember;
groupId: string;
devices: IDevice[];
isRoomEncrypted: boolean;
}> = ({room, member, groupId, devices, isRoomEncrypted}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const powerLevels = useRoomPowerLevels(cli, room); const powerLevels = useRoomPowerLevels(cli, room);
@ -1186,7 +1258,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
const roomPermissions = useRoomPermissions(cli, room, member); const roomPermissions = useRoomPermissions(cli, room, member);
const onSynapseDeactivate = useCallback(async () => { const onSynapseDeactivate = useCallback(async () => {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, { const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, {
title: _t("Deactivate user?"), title: _t("Deactivate user?"),
description: description:
@ -1207,7 +1278,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
console.error("Failed to deactivate user"); console.error("Failed to deactivate user");
console.error(err); console.error(err);
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Failed to deactivate Synapse user', '', ErrorDialog, { Modal.createTrackedDialog('Failed to deactivate Synapse user', '', ErrorDialog, {
title: _t('Failed to deactivate user'), title: _t('Failed to deactivate user'),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
@ -1260,8 +1330,7 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
} }
if (pendingUpdateCount > 0) { if (pendingUpdateCount > 0) {
const Loader = sdk.getComponent("elements.Spinner"); spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />;
spinner = <Loader imgClassName="mx_ContextualMenu_spinner" />;
} }
let memberDetails; let memberDetails;
@ -1296,7 +1365,8 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
const userTrust = cryptoEnabled && cli.checkUserTrust(member.userId); const userTrust = cryptoEnabled && cli.checkUserTrust(member.userId);
const userVerified = cryptoEnabled && userTrust.isCrossSigningVerified(); const userVerified = cryptoEnabled && userTrust.isCrossSigningVerified();
const isMe = member.userId === cli.getUserId(); const isMe = member.userId === cli.getUserId();
const canVerify = cryptoEnabled && homeserverSupportsCrossSigning && !userVerified && !isMe; const canVerify = cryptoEnabled && homeserverSupportsCrossSigning && !userVerified && !isMe &&
devices && devices.length > 0;
const setUpdating = (updating) => { const setUpdating = (updating) => {
setPendingUpdateCount(count => count + (updating ? 1 : -1)); setPendingUpdateCount(count => count + (updating ? 1 : -1));
@ -1323,7 +1393,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
// HACK: only show a spinner if the device section spinner is not shown, // HACK: only show a spinner if the device section spinner is not shown,
// to avoid showing a double spinner // to avoid showing a double spinner
// We should ask for a design that includes all the different loading states here // We should ask for a design that includes all the different loading states here
const Spinner = sdk.getComponent('elements.Spinner');
verifyButton = <Spinner />; verifyButton = <Spinner />;
} }
} }
@ -1350,7 +1419,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
{ securitySection } { securitySection }
<UserOptionsSection <UserOptionsSection
devices={devices}
canInvite={roomPermissions.canInvite} canInvite={roomPermissions.canInvite}
isIgnored={isIgnored} isIgnored={isIgnored}
member={member} /> member={member} />
@ -1361,7 +1429,12 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
</React.Fragment>; </React.Fragment>;
}; };
const UserInfoHeader = ({member, e2eStatus}) => { type Member = User | RoomMember | GroupMember;
const UserInfoHeader: React.FC<{
member: Member;
e2eStatus: E2EStatus;
}> = ({member, e2eStatus}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const onMemberAvatarClick = useCallback(() => { const onMemberAvatarClick = useCallback(() => {
@ -1369,7 +1442,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
if (!avatarUrl) return; if (!avatarUrl) return;
const httpUrl = cli.mxcUrlToHttp(avatarUrl); const httpUrl = cli.mxcUrlToHttp(avatarUrl);
const ImageView = sdk.getComponent("elements.ImageView");
const params = { const params = {
src: httpUrl, src: httpUrl,
name: member.name, name: member.name,
@ -1378,7 +1450,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
}, [cli, member]); }, [cli, member]);
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const avatarElement = ( const avatarElement = (
<div className="mx_UserInfo_avatar"> <div className="mx_UserInfo_avatar">
<div> <div>
@ -1420,10 +1491,13 @@ const UserInfoHeader = ({member, e2eStatus}) => {
let presenceLabel = null; let presenceLabel = null;
if (showPresence) { if (showPresence) {
const PresenceLabel = sdk.getComponent('rooms.PresenceLabel'); presenceLabel = (
presenceLabel = <PresenceLabel activeAgo={presenceLastActiveAgo} <PresenceLabel
activeAgo={presenceLastActiveAgo}
currentlyActive={presenceCurrentlyActive} currentlyActive={presenceCurrentlyActive}
presenceState={presenceState} />; presenceState={presenceState}
/>
);
} }
let statusLabel = null; let statusLabel = null;
@ -1460,7 +1534,32 @@ const UserInfoHeader = ({member, e2eStatus}) => {
</React.Fragment>; </React.Fragment>;
}; };
const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemberInfo, ...props}) => { interface IProps {
user: Member;
groupId?: string;
room?: Room;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo;
onClose(): void;
}
interface IPropsWithEncryptionPanel extends React.ComponentProps<typeof EncryptionPanel> {
user: Member;
groupId: void;
room: Room;
phase: RightPanelPhases.EncryptionPanel;
onClose(): void;
}
type Props = IProps | IPropsWithEncryptionPanel;
const UserInfo: React.FC<Props> = ({
user,
groupId,
room,
onClose,
phase = RightPanelPhases.RoomMemberInfo,
...props
}) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
// fetch latest room member if we have a room, so we don't show historical information, falling back to user // fetch latest room member if we have a room, so we don't show historical information, falling back to user
@ -1484,7 +1583,7 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
<BasicUserInfo <BasicUserInfo
room={room} room={room}
member={member} member={member}
groupId={groupId} groupId={groupId as string}
devices={devices} devices={devices}
isRoomEncrypted={isRoomEncrypted} /> isRoomEncrypted={isRoomEncrypted} />
); );
@ -1492,7 +1591,12 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
case RightPanelPhases.EncryptionPanel: case RightPanelPhases.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar"); classes.push("mx_UserInfo_smallAvatar");
content = ( content = (
<EncryptionPanel {...props} member={member} onClose={onClose} isRoomEncrypted={isRoomEncrypted} /> <EncryptionPanel
{...props as React.ComponentProps<typeof EncryptionPanel>}
member={member}
onClose={onClose}
isRoomEncrypted={isRoomEncrypted}
/>
); );
break; break;
} }
@ -1503,23 +1607,24 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
previousPhase = RightPanelPhases.RoomMemberList; previousPhase = RightPanelPhases.RoomMemberList;
} }
const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} onClose={onClose} />; let closeLabel = undefined;
return <BaseCard className={classes.join(" ")} header={header} onClose={onClose} previousPhase={previousPhase}> if (phase === RightPanelPhases.EncryptionPanel) {
const verificationRequest = (props as React.ComponentProps<typeof EncryptionPanel>).verificationRequest;
if (verificationRequest && verificationRequest.pending) {
closeLabel = _t("Cancel");
}
}
const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} />;
return <BaseCard
className={classes.join(" ")}
header={header}
onClose={onClose}
closeLabel={closeLabel}
previousPhase={previousPhase}
>
{ content } { content }
</BaseCard>; </BaseCard>;
}; };
UserInfo.propTypes = {
user: PropTypes.oneOfType([
PropTypes.instanceOf(User),
PropTypes.instanceOf(RoomMember),
GroupMember,
]).isRequired,
group: PropTypes.instanceOf(Group),
groupId: PropTypes.string,
room: PropTypes.instanceOf(Room),
onClose: PropTypes.func,
};
export default UserInfo; export default UserInfo;

View file

@ -29,16 +29,17 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore"; import WidgetStore from "../../../stores/WidgetStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu"; import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
import IconizedContextMenu, { import IconizedContextMenu, {
IconizedContextMenuOption, IconizedContextMenuOption,
IconizedContextMenuOptionList, IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu"; } from "../context_menus/IconizedContextMenu";
import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload"; import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
import {Capability} from "../../../widgets/WidgetApi";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import classNames from "classnames"; import classNames from "classnames";
import dis from "../../../dispatcher/dispatcher";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { MatrixCapabilities } from "matrix-widget-api";
interface IProps { interface IProps {
room: Room; room: Room;
@ -77,9 +78,17 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
let contextMenu; let contextMenu;
if (menuDisplayed) { if (menuDisplayed) {
let snapshotButton; let snapshotButton;
if (ActiveWidgetStore.widgetHasCapability(app.id, Capability.Screenshot)) { const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
const onSnapshotClick = () => { const onSnapshotClick = () => {
WidgetUtils.snapshotWidget(app); widgetMessaging.takeScreenshot().then(data => {
dis.dispatch({
action: 'picture_snapshot',
file: data.screenshot,
});
}).catch(err => {
console.error("Failed to take screenshot: ", err);
});
closeMenu(); closeMenu();
}; };

View file

@ -75,6 +75,15 @@ export default class RoomProfileSettings extends React.Component {
}); });
}; };
_clearProfile = async (e) => {
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
this._removeAvatar();
this.setState({enableProfileSave: false, displayName: this.state.originalDisplayName});
};
_saveProfile = async (e) => { _saveProfile = async (e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -150,7 +159,12 @@ export default class RoomProfileSettings extends React.Component {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AvatarSetting = sdk.getComponent('settings.AvatarSetting'); const AvatarSetting = sdk.getComponent('settings.AvatarSetting');
return ( return (
<form onSubmit={this._saveProfile} autoComplete="off" noValidate={true}> <form
onSubmit={this._saveProfile}
autoComplete="off"
noValidate={true}
className="mx_ProfileSettings_profileForm"
>
<input type="file" ref={this._avatarUpload} className="mx_ProfileSettings_avatarUpload" <input type="file" ref={this._avatarUpload} className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged} accept="image/*" /> onChange={this._onAvatarChanged} accept="image/*" />
<div className="mx_ProfileSettings_profile"> <div className="mx_ProfileSettings_profile">
@ -169,10 +183,22 @@ export default class RoomProfileSettings extends React.Component {
uploadAvatar={this.state.canSetAvatar ? this._uploadAvatar : undefined} uploadAvatar={this.state.canSetAvatar ? this._uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this._removeAvatar : undefined} /> removeAvatar={this.state.canSetAvatar ? this._removeAvatar : undefined} />
</div> </div>
<AccessibleButton onClick={this._saveProfile} kind="primary" <div className="mx_ProfileSettings_buttons">
disabled={!this.state.enableProfileSave}> <AccessibleButton
onClick={this._clearProfile}
kind="link"
disabled={!this.state.enableProfileSave}
>
{_t("Cancel")}
</AccessibleButton>
<AccessibleButton
onClick={this._saveProfile}
kind="primary"
disabled={!this.state.enableProfileSave}
>
{_t("Save")} {_t("Save")}
</AccessibleButton> </AccessibleButton>
</div>
</form> </form>
); );
} }

View file

@ -39,15 +39,9 @@ export default class AuxPanel extends React.Component {
showApps: PropTypes.bool, // Render apps showApps: PropTypes.bool, // Render apps
hideAppsDrawer: PropTypes.bool, // Do not display apps drawer and content (may still be rendered) hideAppsDrawer: PropTypes.bool, // Do not display apps drawer and content (may still be rendered)
// Conference Handler implementation
conferenceHandler: PropTypes.object,
// set to true to show the file drop target // set to true to show the file drop target
draggingFile: PropTypes.bool, draggingFile: PropTypes.bool,
// set to true to show the 'active conf call' banner
displayConfCallNotification: PropTypes.bool,
// maxHeight attribute for the aux panel and the video // maxHeight attribute for the aux panel and the video
// therein // therein
maxHeight: PropTypes.number, maxHeight: PropTypes.number,
@ -161,39 +155,9 @@ export default class AuxPanel extends React.Component {
); );
} }
let conferenceCallNotification = null;
if (this.props.displayConfCallNotification) {
let supportedText = '';
let joinNode;
if (!MatrixClientPeg.get().supportsVoip()) {
supportedText = _t(" (unsupported)");
} else {
joinNode = (<span>
{ _t(
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
{},
{
'voiceText': (sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice');}} href="#">{ sub }</a>,
'videoText': (sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video');}} href="#">{ sub }</a>,
},
) }
</span>);
}
// XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages,
// but there are translations for this in the languages we do have so I'm leaving it for now.
conferenceCallNotification = (
<div className="mx_RoomView_ongoingConfCallNotification">
{ _t("Ongoing conference call%(supportedText)s.", {supportedText: supportedText}) }
&nbsp;
{ joinNode }
</div>
);
}
const callView = ( const callView = (
<CallView <CallView
room={this.props.room} room={this.props.room}
ConferenceHandler={this.props.conferenceHandler}
onResize={this.props.onResize} onResize={this.props.onResize}
maxVideoHeight={this.props.maxHeight} maxVideoHeight={this.props.maxHeight}
/> />
@ -276,7 +240,6 @@ export default class AuxPanel extends React.Component {
{ appsDrawer } { appsDrawer }
{ fileDropTarget } { fileDropTarget }
{ callView } { callView }
{ conferenceCallNotification }
{ this.props.children } { this.props.children }
</AutoHideScrollbar> </AutoHideScrollbar>
); );

View file

@ -92,7 +92,7 @@ interface IProps {
label?: string; label?: string;
initialCaret?: DocumentOffset; initialCaret?: DocumentOffset;
onChange(); onChange?();
onPaste?(event: ClipboardEvent<HTMLDivElement>, model: EditorModel): boolean; onPaste?(event: ClipboardEvent<HTMLDivElement>, model: EditorModel): boolean;
} }
@ -619,13 +619,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
private onFormatAction = (action: Formatting) => { private onFormatAction = (action: Formatting) => {
const range = getRangeForSelection( const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
this.editorRef.current, // trim the range as we want it to exclude leading/trailing spaces
this.props.model, range.trim();
document.getSelection());
if (range.length === 0) { if (range.length === 0) {
return; return;
} }
this.historyManager.ensureLastChangesPushed(this.props.model); this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true; this.modifiedFlag = true;
switch (action) { switch (action) {

View file

@ -34,6 +34,7 @@ import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon"; import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units"; import {toRem} from "../../../utils/units";
import {WidgetType} from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
const eventTileTypes = { const eventTileTypes = {
@ -111,6 +112,19 @@ export function getHandlerTile(ev) {
} }
} }
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
if (type === "im.vector.modular.widgets") {
let type = ev.getContent()['type'];
if (!type) {
// deleted/invalid widget - try the past widget type
type = ev.getPrevContent()['type'];
}
if (WidgetType.JITSI.matches(type)) {
return "messages.MJitsiWidgetEvent";
}
}
return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type]; return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type];
} }
@ -627,16 +641,18 @@ export default class EventTile extends React.Component {
const msgtype = content.msgtype; const msgtype = content.msgtype;
const eventType = this.props.mxEvent.getType(); const eventType = this.props.mxEvent.getType();
let tileHandler = getHandlerTile(this.props.mxEvent);
// Info messages are basically information about commands processed on a room // Info messages are basically information about commands processed on a room
const isBubbleMessage = eventType.startsWith("m.key.verification") || const isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) || (eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === "m.room.encryption"); (eventType === "m.room.encryption") ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = ( let isInfoMessage = (
!isBubbleMessage && eventType !== 'm.room.message' && !isBubbleMessage && eventType !== 'm.room.message' &&
eventType !== 'm.sticker' && eventType !== 'm.room.create' eventType !== 'm.sticker' && eventType !== 'm.room.create'
); );
let tileHandler = getHandlerTile(this.props.mxEvent);
// If we're showing hidden events in the timeline, we should use the // If we're showing hidden events in the timeline, we should use the
// source tile when there's no regular tile for an event and also for // source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing // replace relations (which otherwise would display as a confusing
@ -902,6 +918,7 @@ export default class EventTile extends React.Component {
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false} /> showUrlPreview={false} />
</div> </div>
</div> </div>

View file

@ -24,7 +24,6 @@ import {isValid3pidInvite} from "../../../RoomInvite";
import rate_limited_func from "../../../ratelimitedfunc"; import rate_limited_func from "../../../ratelimitedfunc";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import CallHandler from "../../../CallHandler";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard"; import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
@ -122,8 +121,8 @@ export default class MemberList extends React.Component {
this.setState(this._getMembersState(this.roomMembers())); this.setState(this._getMembersState(this.roomMembers()));
this._listenForMembersChanges(); this._listenForMembersChanges();
} }
} else if (membership === "invite") { } else {
// show the members we've got when invited // show the members we already have loaded
this.setState(this._getMembersState(this.roomMembers())); this.setState(this._getMembersState(this.roomMembers()));
} }
} }
@ -233,15 +232,10 @@ export default class MemberList extends React.Component {
} }
roomMembers() { roomMembers() {
const ConferenceHandler = CallHandler.getConferenceHandler();
const allMembers = this.getMembersWithUser(); const allMembers = this.getMembersWithUser();
const filteredAndSortedMembers = allMembers.filter((m) => { const filteredAndSortedMembers = allMembers.filter((m) => {
return ( return (
m.membership === 'join' || m.membership === 'invite' m.membership === 'join' || m.membership === 'invite'
) && (
!ConferenceHandler ||
(ConferenceHandler && !ConferenceHandler.isConferenceUser(m.userId))
); );
}); });
filteredAndSortedMembers.sort(this.memberSort); filteredAndSortedMembers.sort(this.memberSort);

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -32,6 +33,10 @@ import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview"; import ReplyPreview from "./ReplyPreview";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import WidgetStore from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
function ComposerAvatar(props) { function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -85,9 +90,16 @@ VideoCallButton.propTypes = {
}; };
function HangupButton(props) { function HangupButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onHangupClick = () => { const onHangupClick = () => {
const call = CallHandler.getCallForRoom(props.roomId); if (props.isConference) {
dis.dispatch({
action: props.canEndConference ? 'end_conference' : 'hangup_conference',
room_id: props.roomId,
});
return;
}
const call = CallHandler.sharedInstance().getCallForRoom(props.roomId);
if (!call) { if (!call) {
return; return;
} }
@ -98,14 +110,28 @@ function HangupButton(props) {
room_id: call.roomId, room_id: call.roomId,
}); });
}; };
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
let tooltip = _t("Hangup");
if (props.isConference && props.canEndConference) {
tooltip = _t("End conference");
}
const canLeaveConference = !props.isConference ? true : props.isInConference;
return (
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick} onClick={onHangupClick}
title={_t('Hangup')} title={tooltip}
/>); disabled={!canLeaveConference}
/>
);
} }
HangupButton.propTypes = { HangupButton.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
isConference: PropTypes.bool.isRequired,
canEndConference: PropTypes.bool,
isInConference: PropTypes.bool,
}; };
const EmojiButton = ({addEmoji}) => { const EmojiButton = ({addEmoji}) => {
@ -226,12 +252,17 @@ export default class MessageComposer extends React.Component {
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this); this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetUpdate);
this._dispatcherRef = null; this._dispatcherRef = null;
this.state = { this.state = {
isQuoting: Boolean(RoomViewStore.getQuotingEvent()), replyToEvent: RoomViewStore.getQuotingEvent(),
tombstone: this._getRoomTombstone(), tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(), canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"), showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room),
joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
}; };
} }
@ -247,6 +278,14 @@ export default class MessageComposer extends React.Component {
} }
}; };
_onWidgetUpdate = () => {
this.setState({hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room)});
};
_onActiveWidgetUpdate = () => {
this.setState({joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room)});
};
componentDidMount() { componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
@ -277,6 +316,8 @@ export default class MessageComposer extends React.Component {
if (this._roomStoreToken) { if (this._roomStoreToken) {
this._roomStoreToken.remove(); this._roomStoreToken.remove();
} }
WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
@ -296,9 +337,9 @@ export default class MessageComposer extends React.Component {
} }
_onRoomViewStoreUpdate() { _onRoomViewStoreUpdate() {
const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); const replyToEvent = RoomViewStore.getQuotingEvent();
if (this.state.isQuoting === isQuoting) return; if (this.state.replyToEvent === replyToEvent) return;
this.setState({ isQuoting }); this.setState({ replyToEvent });
} }
onInputStateChanged(inputState) { onInputStateChanged(inputState) {
@ -337,7 +378,7 @@ export default class MessageComposer extends React.Component {
} }
renderPlaceholderText() { renderPlaceholderText() {
if (this.state.isQuoting) { if (this.state.replyToEvent) {
if (this.props.e2eStatus) { if (this.props.e2eStatus) {
return _t('Send an encrypted reply…'); return _t('Send an encrypted reply…');
} else { } else {
@ -382,7 +423,9 @@ export default class MessageComposer extends React.Component {
room={this.props.room} room={this.props.room}
placeholder={this.renderPlaceholderText()} placeholder={this.renderPlaceholderText()}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.props.permalinkCreator} />, permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.state.replyToEvent}
/>,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />, <UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />, <EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,
); );
@ -392,9 +435,20 @@ export default class MessageComposer extends React.Component {
} }
if (this.state.showCallButtons) { if (this.state.showCallButtons) {
if (callInProgress) { if (this.state.hasConference) {
const canEndConf = WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
controls.push( controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} />, <HangupButton
key="controls_hangup"
roomId={this.props.room.roomId}
isConference={true}
canEndConference={canEndConf}
isInConference={this.state.joinedConference}
/>,
);
} else if (callInProgress) {
controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} isConference={false} />,
); );
} else { } else {
controls.push( controls.push(

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018-2020 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -28,6 +28,11 @@ export default class RoomUpgradeWarningBar extends React.Component {
recommendation: PropTypes.object.isRequired, recommendation: PropTypes.object.isRequired,
}; };
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() { componentDidMount() {
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", ""); const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({upgraded: tombstone && tombstone.getContent().replacement_room}); this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
@ -35,6 +40,13 @@ export default class RoomUpgradeWarningBar extends React.Component {
MatrixClientPeg.get().on("RoomState.events", this._onStateEvents); MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
} }
componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onStateEvents);
}
}
_onStateEvents = (event, state) => { _onStateEvents = (event, state) => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return; return;

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,6 +20,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import classNames from "classnames"; import classNames from "classnames";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import DesktopBuildsNotice, {WarningKind} from "../elements/DesktopBuildsNotice";
export default class SearchBar extends React.Component { export default class SearchBar extends React.Component {
constructor(props) { constructor(props) {
@ -72,6 +74,7 @@ export default class SearchBar extends React.Component {
}); });
return ( return (
<>
<div className="mx_SearchBar"> <div className="mx_SearchBar">
<div className="mx_SearchBar_buttons" role="radiogroup"> <div className="mx_SearchBar_buttons" role="radiogroup">
<AccessibleButton className={ thisRoomClasses } onClick={this.onThisRoomClick} aria-checked={this.state.scope === 'Room'} role="radio"> <AccessibleButton className={ thisRoomClasses } onClick={this.onThisRoomClick} aria-checked={this.state.scope === 'Room'} role="radio">
@ -87,6 +90,8 @@ export default class SearchBar extends React.Component {
</div> </div>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick} /> <AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick} />
</div> </div>
<DesktopBuildsNotice isRoomEncrypted={this.props.isRoomEncrypted} kind={WarningKind.Search} />
</>
); );
} }
} }

View file

@ -29,7 +29,6 @@ import {
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import {CommandPartCreator} from '../../../editor/parts'; import {CommandPartCreator} from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import {parseEvent} from '../../../editor/deserialize'; import {parseEvent} from '../../../editor/deserialize';
import {findEditableEvent} from '../../../utils/EventUtils'; import {findEditableEvent} from '../../../utils/EventUtils';
@ -41,7 +40,6 @@ import {_t, _td} from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages'; import ContentMessages from '../../../ContentMessages';
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RateLimitedFunc from '../../../ratelimitedfunc'; import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
@ -61,7 +59,7 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
} }
// exported for tests // exported for tests
export function createMessageContent(model, permalinkCreator) { export function createMessageContent(model, permalinkCreator, replyToEvent) {
const isEmote = containsEmote(model); const isEmote = containsEmote(model);
if (isEmote) { if (isEmote) {
model = stripEmoteCommand(model); model = stripEmoteCommand(model);
@ -70,21 +68,20 @@ export function createMessageContent(model, permalinkCreator) {
model = stripPrefix(model, "/"); model = stripPrefix(model, "/");
} }
model = unescapeMessage(model); model = unescapeMessage(model);
const repliedToEvent = RoomViewStore.getQuotingEvent();
const body = textSerialize(model); const body = textSerialize(model);
const content = { const content = {
msgtype: isEmote ? "m.emote" : "m.text", msgtype: isEmote ? "m.emote" : "m.text",
body: body, body: body,
}; };
const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: !!repliedToEvent}); const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: !!replyToEvent});
if (formattedBody) { if (formattedBody) {
content.format = "org.matrix.custom.html"; content.format = "org.matrix.custom.html";
content.formatted_body = formattedBody; content.formatted_body = formattedBody;
} }
if (repliedToEvent) { if (replyToEvent) {
addReplyToMessageContent(content, repliedToEvent, permalinkCreator); addReplyToMessageContent(content, replyToEvent, permalinkCreator);
} }
return content; return content;
@ -95,6 +92,7 @@ export default class SendMessageComposer extends React.Component {
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,
placeholder: PropTypes.string, placeholder: PropTypes.string,
permalinkCreator: PropTypes.object.isRequired, permalinkCreator: PropTypes.object.isRequired,
replyToEvent: PropTypes.object,
}; };
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
@ -104,12 +102,13 @@ export default class SendMessageComposer extends React.Component {
this.model = null; this.model = null;
this._editorRef = null; this._editorRef = null;
this.currentlyComposedEditorState = null; this.currentlyComposedEditorState = null;
const cli = MatrixClientPeg.get(); if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) {
if (cli.isCryptoEnabled() && cli.isRoomEncrypted(this.props.room.roomId)) {
this._prepareToEncrypt = new RateLimitedFunc(() => { this._prepareToEncrypt = new RateLimitedFunc(() => {
cli.prepareToEncrypt(this.props.room); this.context.prepareToEncrypt(this.props.room);
}, 60000); }, 60000);
} }
window.addEventListener("beforeunload", this._saveStoredEditorState);
} }
_setEditorRef = ref => { _setEditorRef = ref => {
@ -145,7 +144,7 @@ export default class SendMessageComposer extends React.Component {
if (e.shiftKey || e.metaKey) return; if (e.shiftKey || e.metaKey) return;
const shouldSelectHistory = e.altKey && e.ctrlKey; const shouldSelectHistory = e.altKey && e.ctrlKey;
const shouldEditLastMessage = !e.altKey && !e.ctrlKey && up && !RoomViewStore.getQuotingEvent(); const shouldEditLastMessage = !e.altKey && !e.ctrlKey && up && !this.props.replyToEvent;
if (shouldSelectHistory) { if (shouldSelectHistory) {
// Try select composer history // Try select composer history
@ -187,9 +186,13 @@ export default class SendMessageComposer extends React.Component {
this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length; this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length;
return; return;
} }
const serializedParts = this.sendHistoryManager.getItem(delta); const {parts, replyEventId} = this.sendHistoryManager.getItem(delta);
if (serializedParts) { dis.dispatch({
this.model.reset(serializedParts); action: 'reply_to_event',
event: replyEventId ? this.props.room.findEventById(replyEventId) : null,
});
if (parts) {
this.model.reset(parts);
this._editorRef.focus(); this._editorRef.focus();
} }
} }
@ -299,12 +302,12 @@ export default class SendMessageComposer extends React.Component {
} }
} }
const replyToEvent = this.props.replyToEvent;
if (shouldSend) { if (shouldSend) {
const isReply = !!RoomViewStore.getQuotingEvent();
const {roomId} = this.props.room; const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator); const content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent);
this.context.sendMessage(roomId, content); this.context.sendMessage(roomId, content);
if (isReply) { if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue // Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending. // if the send fails, retry will handle resending.
dis.dispatch({ dis.dispatch({
@ -315,7 +318,7 @@ export default class SendMessageComposer extends React.Component {
dis.dispatch({action: "message_sent"}); dis.dispatch({action: "message_sent"});
} }
this.sendHistoryManager.save(this.model); this.sendHistoryManager.save(this.model, replyToEvent);
// clear composer // clear composer
this.model.reset([]); this.model.reset([]);
this._editorRef.clearUndoHistory(); this._editorRef.clearUndoHistory();
@ -325,6 +328,8 @@ export default class SendMessageComposer extends React.Component {
componentWillUnmount() { componentWillUnmount() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
window.removeEventListener("beforeunload", this._saveStoredEditorState);
this._saveStoredEditorState();
} }
// TODO: [REACT-WARNING] Move this to constructor // TODO: [REACT-WARNING] Move this to constructor
@ -333,11 +338,11 @@ export default class SendMessageComposer extends React.Component {
const parts = this._restoreStoredEditorState(partCreator) || []; const parts = this._restoreStoredEditorState(partCreator) || [];
this.model = new EditorModel(parts, partCreator); this.model = new EditorModel(parts, partCreator);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_composer_history_'); this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_');
} }
get _editorStateKey() { get _editorStateKey() {
return `cider_editor_state_${this.props.room.roomId}`; return `mx_cider_state_${this.props.room.roomId}`;
} }
_clearStoredEditorState() { _clearStoredEditorState() {
@ -347,9 +352,19 @@ export default class SendMessageComposer extends React.Component {
_restoreStoredEditorState(partCreator) { _restoreStoredEditorState(partCreator) {
const json = localStorage.getItem(this._editorStateKey); const json = localStorage.getItem(this._editorStateKey);
if (json) { if (json) {
const serializedParts = JSON.parse(json); try {
const {parts: serializedParts, replyEventId} = JSON.parse(json);
const parts = serializedParts.map(p => partCreator.deserializePart(p)); const parts = serializedParts.map(p => partCreator.deserializePart(p));
if (replyEventId) {
dis.dispatch({
action: 'reply_to_event',
event: this.props.room.findEventById(replyEventId),
});
}
return parts; return parts;
} catch (e) {
console.error(e);
}
} }
} }
@ -357,7 +372,8 @@ export default class SendMessageComposer extends React.Component {
if (this.model.isEmpty) { if (this.model.isEmpty) {
this._clearStoredEditorState(); this._clearStoredEditorState();
} else { } else {
localStorage.setItem(this._editorStateKey, JSON.stringify(this.model.serializeParts())); const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
localStorage.setItem(this._editorStateKey, JSON.stringify(item));
} }
} }
@ -449,7 +465,6 @@ export default class SendMessageComposer extends React.Component {
room={this.props.room} room={this.props.room}
label={this.props.placeholder} label={this.props.placeholder}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
onChange={this._saveStoredEditorState}
onPaste={this._onPaste} onPaste={this._onPaste}
/> />
</div> </div>

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