Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/feat/modal-widgets

 Conflicts:
	src/@types/global.d.ts
	src/FromWidgetPostMessageApi.js
	src/WidgetMessaging.js
	src/widgets/WidgetApi.ts
This commit is contained in:
Michael Telatynski 2020-10-19 19:46:07 +01:00
commit 00b1a03a3e
171 changed files with 5309 additions and 4610 deletions

View file

@ -1,50 +1,31 @@
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
src/components/structures/RoomDirectory.js
src/components/structures/RoomStatusBar.js
src/components/structures/ScrollPanel.js
src/components/structures/SearchBox.js
src/components/structures/UploadBar.js
src/components/views/avatars/MemberAvatar.js
src/components/views/create_room/RoomAlias.js
src/components/views/dialogs/SetPasswordDialog.js
src/components/views/elements/AddressSelector.js
src/components/views/elements/DirectorySearchBox.js
src/components/views/elements/MemberEventListSummary.js
src/components/views/elements/UserSelector.js
src/components/views/globals/NewVersionBar.js
src/components/views/messages/MFileBody.js
src/components/views/messages/TextualBody.js
src/components/views/room_settings/ColorSettings.js
src/components/views/rooms/Autocomplete.js
src/components/views/rooms/AuxPanel.js
src/components/views/rooms/LinkPreviewWidget.js
src/components/views/rooms/MemberInfo.js
src/components/views/rooms/MemberList.js
src/components/views/rooms/RoomList.js
src/components/views/rooms/RoomPreviewBar.js
src/components/views/rooms/SearchResultTile.js
src/components/views/settings/ChangeAvatar.js
src/components/views/settings/ChangePassword.js
src/components/views/settings/DevicesPanel.js
src/components/views/settings/Notifications.js
src/HtmlUtils.js
src/ImageUtils.js
src/Markdown.js
src/notifications/ContentRules.js
src/notifications/PushRuleVectorState.js
src/PlatformPeg.js
src/rageshake/rageshake.js
src/ratelimitedfunc.js
src/Rooms.js
src/Unread.js
src/Velociraptor.js
src/components/structures/RoomDirectory.js
src/components/structures/ScrollPanel.js
src/components/structures/UploadBar.js
src/components/views/elements/AddressSelector.js
src/components/views/elements/DirectorySearchBox.js
src/components/views/messages/MFileBody.js
src/components/views/messages/TextualBody.js
src/components/views/rooms/AuxPanel.js
src/components/views/rooms/LinkPreviewWidget.js
src/components/views/rooms/MemberList.js
src/components/views/rooms/RoomPreviewBar.js
src/components/views/settings/ChangeAvatar.js
src/components/views/settings/DevicesPanel.js
src/components/views/settings/Notifications.js
src/rageshake/rageshake.js
src/ratelimitedfunc.js
src/utils/DMRoomMap.js
src/utils/DecryptFile.js
src/utils/DirectoryUtils.js
src/utils/DMRoomMap.js
src/utils/FormattingUtils.js
src/utils/MultiInviter.js
src/utils/Receipt.js
src/Velociraptor.js
test/components/structures/MessagePanel-test.js
test/components/views/dialogs/InteractiveAuthDialog-test.js
test/mock-clock.js

View file

@ -1,3 +1,219 @@
Changes in [3.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0) (2020-10-12)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0-rc.1...v3.6.0)
* Upgrade JS SDK to 8.5.0
* [Release] Fix templating for v1 jitsi widgets
[\#5306](https://github.com/matrix-org/matrix-react-sdk/pull/5306)
* [Release] Use new preparing event for widget communications
[\#5304](https://github.com/matrix-org/matrix-react-sdk/pull/5304)
Changes in [3.6.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0-rc.1) (2020-10-07)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0...v3.6.0-rc.1)
* Upgrade JS SDK to 8.5.0-rc.1
* Update from Weblate
[\#5297](https://github.com/matrix-org/matrix-react-sdk/pull/5297)
* Fix edited replies being wrongly treated as big emoji
[\#5295](https://github.com/matrix-org/matrix-react-sdk/pull/5295)
* Fix StopGapWidget infinitely recursing
[\#5294](https://github.com/matrix-org/matrix-react-sdk/pull/5294)
* Fix editing and redactions not updating the Reply Thread
[\#5281](https://github.com/matrix-org/matrix-react-sdk/pull/5281)
* Hide Jump to Read Receipt button for users who have not yet sent an RR
[\#5282](https://github.com/matrix-org/matrix-react-sdk/pull/5282)
* fix img tags not always being rendered correctly
[\#5279](https://github.com/matrix-org/matrix-react-sdk/pull/5279)
* Hopefully fix righhtpanel crash
[\#5293](https://github.com/matrix-org/matrix-react-sdk/pull/5293)
* Fix naive pinning limit and app tile widgetMessaging NPE
[\#5283](https://github.com/matrix-org/matrix-react-sdk/pull/5283)
* Show server errors from saving profile settings
[\#5272](https://github.com/matrix-org/matrix-react-sdk/pull/5272)
* Update copy for `redact` permission
[\#5273](https://github.com/matrix-org/matrix-react-sdk/pull/5273)
* Remove width limit on widgets
[\#5265](https://github.com/matrix-org/matrix-react-sdk/pull/5265)
* Fix call container avatar initial centering
[\#5280](https://github.com/matrix-org/matrix-react-sdk/pull/5280)
* Fix right panel for peeking rooms
[\#5268](https://github.com/matrix-org/matrix-react-sdk/pull/5268)
* Add support for dehydrated devices
[\#5239](https://github.com/matrix-org/matrix-react-sdk/pull/5239)
* Use Own Profile Store for the Profile Settings
[\#5277](https://github.com/matrix-org/matrix-react-sdk/pull/5277)
* null-guard defaultAvatarUrlForString
[\#5270](https://github.com/matrix-org/matrix-react-sdk/pull/5270)
* Choose first result on enter in the emoji picker
[\#5257](https://github.com/matrix-org/matrix-react-sdk/pull/5257)
* Fix room directory clipping links in the room's topic
[\#5276](https://github.com/matrix-org/matrix-react-sdk/pull/5276)
* Decorate failed e2ee downgrade attempts better
[\#5278](https://github.com/matrix-org/matrix-react-sdk/pull/5278)
* MELS use latest avatar rather than the first avatar
[\#5262](https://github.com/matrix-org/matrix-react-sdk/pull/5262)
* Fix Encryption Panel close button clashing with Base Card
[\#5261](https://github.com/matrix-org/matrix-react-sdk/pull/5261)
* Wrap canEncryptToAllUsers in a try/catch to handle server errors
[\#5275](https://github.com/matrix-org/matrix-react-sdk/pull/5275)
* Fix conditional on communities prototype room creation dialog
[\#5274](https://github.com/matrix-org/matrix-react-sdk/pull/5274)
* Fix ensureDmExists for encryption detection
[\#5271](https://github.com/matrix-org/matrix-react-sdk/pull/5271)
* Switch to using the Widget API SDK for widget messaging
[\#5171](https://github.com/matrix-org/matrix-react-sdk/pull/5171)
* Ensure package links exist when releasing
[\#5269](https://github.com/matrix-org/matrix-react-sdk/pull/5269)
* Fix the call preview when not in same room as the call
[\#5267](https://github.com/matrix-org/matrix-react-sdk/pull/5267)
* Make the hangup button do things for conference calls
[\#5223](https://github.com/matrix-org/matrix-react-sdk/pull/5223)
* Render Jitsi widget state events in a more obvious way
[\#5222](https://github.com/matrix-org/matrix-react-sdk/pull/5222)
* Make the PIP Jitsi look and feel like the 1:1 PIP
[\#5226](https://github.com/matrix-org/matrix-react-sdk/pull/5226)
* Trim range when formatting so that it excludes leading/trailing spaces
[\#5263](https://github.com/matrix-org/matrix-react-sdk/pull/5263)
* Fix button label on the Set Password Dialog
[\#5264](https://github.com/matrix-org/matrix-react-sdk/pull/5264)
* fix link to classic yarn's `yarn link`
[\#5259](https://github.com/matrix-org/matrix-react-sdk/pull/5259)
* Fix index mismatch between username colors styles and custom theming
[\#5256](https://github.com/matrix-org/matrix-react-sdk/pull/5256)
* Disable autocompletion on security key input during login
[\#5258](https://github.com/matrix-org/matrix-react-sdk/pull/5258)
* fix uninitialised state and eventlistener leak in RoomUpgradeWarningBar
[\#5255](https://github.com/matrix-org/matrix-react-sdk/pull/5255)
* Only set title when it changes
[\#5254](https://github.com/matrix-org/matrix-react-sdk/pull/5254)
* Convert CallHandler to typescript
[\#5248](https://github.com/matrix-org/matrix-react-sdk/pull/5248)
* Retry loading i18n language if it fails
[\#5209](https://github.com/matrix-org/matrix-react-sdk/pull/5209)
* Rework profile area for user and room settings to be more clear
[\#5243](https://github.com/matrix-org/matrix-react-sdk/pull/5243)
* Validation improve pattern for derived data
[\#5241](https://github.com/matrix-org/matrix-react-sdk/pull/5241)
Changes in [3.5.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0) (2020-09-28)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0-rc.1...v3.5.0)
* Upgrade JS SDK to 8.4.1
Changes in [3.5.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0-rc.1) (2020-09-23)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.1...v3.5.0-rc.1)
* Upgrade JS SDK to 8.4.0-rc.1
* Update from Weblate
[\#5246](https://github.com/matrix-org/matrix-react-sdk/pull/5246)
* Upgrade sanitize-html, set nesting limit
[\#5245](https://github.com/matrix-org/matrix-react-sdk/pull/5245)
* Add a note to use the desktop builds when seshat isn't available
[\#5225](https://github.com/matrix-org/matrix-react-sdk/pull/5225)
* Add some permission checks to the communities v2 prototype
[\#5240](https://github.com/matrix-org/matrix-react-sdk/pull/5240)
* Support HS-preferred Secure Backup setup methods
[\#5242](https://github.com/matrix-org/matrix-react-sdk/pull/5242)
* Only show User Info verify button if the other user has e2ee devices
[\#5234](https://github.com/matrix-org/matrix-react-sdk/pull/5234)
* Fix New Room List arrow key management
[\#5237](https://github.com/matrix-org/matrix-react-sdk/pull/5237)
* Fix Room Directory View & Preview actions for federated joins
[\#5235](https://github.com/matrix-org/matrix-react-sdk/pull/5235)
* Add a UI feature to disable advanced encryption options
[\#5238](https://github.com/matrix-org/matrix-react-sdk/pull/5238)
* UI Feature Flag: Communities
[\#5216](https://github.com/matrix-org/matrix-react-sdk/pull/5216)
* Rename apps back to widgets
[\#5236](https://github.com/matrix-org/matrix-react-sdk/pull/5236)
* Adjust layout and formatting of notifications / files cards
[\#5229](https://github.com/matrix-org/matrix-react-sdk/pull/5229)
* Fix Search Results Tile undefined variable access regression
[\#5232](https://github.com/matrix-org/matrix-react-sdk/pull/5232)
* Fix Cmd/Ctrl+Shift+U for File Upload
[\#5233](https://github.com/matrix-org/matrix-react-sdk/pull/5233)
* Disable the e2ee toggle when creating a room on a server with forced e2e
[\#5231](https://github.com/matrix-org/matrix-react-sdk/pull/5231)
* UI Feature Flag: Disable advanced options and tidy up some copy
[\#5215](https://github.com/matrix-org/matrix-react-sdk/pull/5215)
* UI Feature Flag: 3PIDs
[\#5228](https://github.com/matrix-org/matrix-react-sdk/pull/5228)
* Defer encryption setup until first E2EE room
[\#5219](https://github.com/matrix-org/matrix-react-sdk/pull/5219)
* Tidy devDeps, all the webpack stuff lives in the layer above
[\#5179](https://github.com/matrix-org/matrix-react-sdk/pull/5179)
* UI Feature Flag: Hide flair
[\#5214](https://github.com/matrix-org/matrix-react-sdk/pull/5214)
* UI Feature Flag: Identity server
[\#5218](https://github.com/matrix-org/matrix-react-sdk/pull/5218)
* UI Feature Flag: Share dialog QR code and social icons
[\#5221](https://github.com/matrix-org/matrix-react-sdk/pull/5221)
* UI Feature Flag: Registration, Password Reset, Deactivate
[\#5227](https://github.com/matrix-org/matrix-react-sdk/pull/5227)
* Retry joinRoom up to 5 times in the case of a 504 GATEWAY TIMEOUT
[\#5204](https://github.com/matrix-org/matrix-react-sdk/pull/5204)
* UI Feature Flag: Disable VoIP
[\#5217](https://github.com/matrix-org/matrix-react-sdk/pull/5217)
* Fix setState() usage in the constructor of RoomDirectory
[\#5224](https://github.com/matrix-org/matrix-react-sdk/pull/5224)
* Hide Analytics sections if piwik config is not provided
[\#5211](https://github.com/matrix-org/matrix-react-sdk/pull/5211)
* UI Feature Flag: Disable feedback button
[\#5213](https://github.com/matrix-org/matrix-react-sdk/pull/5213)
* Clean up UserInfo to not show a blank Power Selector for users not in room
[\#5220](https://github.com/matrix-org/matrix-react-sdk/pull/5220)
* Also hide bug reporting prompts from the Error Boundaries
[\#5212](https://github.com/matrix-org/matrix-react-sdk/pull/5212)
* Tactical improvements to 3PID invites
[\#5201](https://github.com/matrix-org/matrix-react-sdk/pull/5201)
* If no bug_report_endpoint_url, hide rageshaking from the App
[\#5210](https://github.com/matrix-org/matrix-react-sdk/pull/5210)
* Introduce a concept of UI features, using it for URL previews at first
[\#5208](https://github.com/matrix-org/matrix-react-sdk/pull/5208)
* Remove defunct "always show encryption icons" setting
[\#5207](https://github.com/matrix-org/matrix-react-sdk/pull/5207)
* Don't show Notifications Prompt Toast if user has master rule enabled
[\#5203](https://github.com/matrix-org/matrix-react-sdk/pull/5203)
* Fix Bridges tab crashing when the room does not have bridges
[\#5206](https://github.com/matrix-org/matrix-react-sdk/pull/5206)
* Don't count widgets which no longer exist towards pinned count
[\#5202](https://github.com/matrix-org/matrix-react-sdk/pull/5202)
* Fix crashes with cannot read isResizing of undefined
[\#5205](https://github.com/matrix-org/matrix-react-sdk/pull/5205)
* Prompt to remove the jitsi widget when pressing the call button
[\#5193](https://github.com/matrix-org/matrix-react-sdk/pull/5193)
* Show verification status in the room summary card
[\#5195](https://github.com/matrix-org/matrix-react-sdk/pull/5195)
* Fix user info scrolling in new card view
[\#5198](https://github.com/matrix-org/matrix-react-sdk/pull/5198)
* Fix sticker picker height
[\#5197](https://github.com/matrix-org/matrix-react-sdk/pull/5197)
* Call jitsi widgets 'group calls'
[\#5191](https://github.com/matrix-org/matrix-react-sdk/pull/5191)
* Don't show 'unpin' for persistent widgets
[\#5194](https://github.com/matrix-org/matrix-react-sdk/pull/5194)
* Split up cross-signing and secure backup settings
[\#5182](https://github.com/matrix-org/matrix-react-sdk/pull/5182)
* Fix onNewScreen to use replace when going from roomId->roomAlias
[\#5185](https://github.com/matrix-org/matrix-react-sdk/pull/5185)
* bring back 1.2M style badge counts rather than 99+
[\#5192](https://github.com/matrix-org/matrix-react-sdk/pull/5192)
* Run the rageshake command through the bug report dialog
[\#5189](https://github.com/matrix-org/matrix-react-sdk/pull/5189)
* Account for via in pill matching regex
[\#5188](https://github.com/matrix-org/matrix-react-sdk/pull/5188)
* Remove now-unused create-react-class from lockfile
[\#5187](https://github.com/matrix-org/matrix-react-sdk/pull/5187)
* Fixed 1px jump upwards
[\#5163](https://github.com/matrix-org/matrix-react-sdk/pull/5163)
* Always allow widgets when using the local version
[\#5184](https://github.com/matrix-org/matrix-react-sdk/pull/5184)
* Migrate RoomView and RoomContext to Typescript
[\#5175](https://github.com/matrix-org/matrix-react-sdk/pull/5175)
Changes in [3.4.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.1) (2020-09-14)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.0...v3.4.1)

View file

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

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",
"version": "3.4.1",
"version": "3.6.0",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -79,6 +79,7 @@
"linkifyjs": "^2.1.9",
"lodash": "^4.17.19",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.5",
"minimist": "^1.2.5",
"pako": "^1.0.11",
"parse5": "^5.1.1",
@ -120,7 +121,7 @@
"@babel/preset-typescript": "^7.10.4",
"@babel/register": "^7.10.5",
"@babel/traverse": "^7.11.0",
"@peculiar/webcrypto": "^1.1.2",
"@peculiar/webcrypto": "^1.1.3",
"@types/classnames": "^2.2.10",
"@types/counterpart": "^0.18.1",
"@types/flux": "^3.1.9",
@ -150,8 +151,9 @@
"eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^2.5.1",
"glob": "^5.0.15",
"jest": "^24.9.0",
"jest-canvas-mock": "^2.2.0",
"jest": "^26.5.2",
"jest-canvas-mock": "^2.3.0",
"jest-environment-jsdom-sixteen": "^1.0.3",
"lolex": "^5.1.2",
"matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.2",
@ -164,6 +166,7 @@
"walk": "^2.3.14"
},
"jest": {
"testEnvironment": "./__test-utils__/environment.js",
"testMatch": [
"<rootDir>/test/**/*-test.js"
],

View file

@ -9,6 +9,9 @@ set -e
cd `dirname $0`
# This link seems to get eaten by the release process, so ensure it exists.
yarn link matrix-js-sdk
for i in matrix-js-sdk
do
echo "Checking version of $i..."

View file

@ -208,12 +208,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
border: 0;
}
/* applied to side-panels and messagepanel when in RoomSettings */
.mx_fadable {
opacity: 1;
transition: opacity 0.2s ease-in-out;
}
// These are magic constants which are excluded from tinting, to let themes
// (which only have CSS, unlike skins) tell the app what their non-tinted
// colourscheme is by inspecting the stylesheet DOM.
@ -262,7 +256,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
font-weight: 300;
font-size: $font-15px;
position: relative;
padding: 25px 30px 30px 30px;
padding: 24px;
max-height: 80%;
box-shadow: 2px 15px 30px 0 $dialog-shadow-color;
border-radius: 8px;

View file

@ -26,7 +26,7 @@
@import "./structures/_ScrollPanel.scss";
@import "./structures/_SearchBox.scss";
@import "./structures/_TabbedView.scss";
@import "./structures/_TagPanel.scss";
@import "./structures/_GroupFilterPanel.scss";
@import "./structures/_ToastContainer.scss";
@import "./structures/_UploadBar.scss";
@import "./structures/_UserMenu.scss";
@ -82,8 +82,6 @@
@import "./views/dialogs/_RoomUpgradeWarningDialog.scss";
@import "./views/dialogs/_ServerOfflineDialog.scss";
@import "./views/dialogs/_SetEmailDialog.scss";
@import "./views/dialogs/_SetMxIdDialog.scss";
@import "./views/dialogs/_SetPasswordDialog.scss";
@import "./views/dialogs/_SettingsDialog.scss";
@import "./views/dialogs/_ShareDialog.scss";
@import "./views/dialogs/_SlashCommandHelpDialog.scss";
@ -142,6 +140,7 @@
@import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss";
@import "./views/messages/_MJitsiWidgetEvent.scss";
@import "./views/messages/_MNoticeBody.scss";
@import "./views/messages/_MStickerBody.scss";
@import "./views/messages/_MTextBody.scss";

View file

@ -22,7 +22,7 @@ limitations under the License.
}
.mx_CustomRoomTagPanel {
background-color: $tagpanel-bg-color;
background-color: $groupFilterPanel-bg-color;
max-height: 40vh;
}

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_TagPanel {
.mx_GroupFilterPanel {
flex: 1;
background-color: $tagpanel-bg-color;
background-color: $groupFilterPanel-bg-color;
cursor: pointer;
display: flex;
@ -26,49 +26,49 @@ limitations under the License.
min-height: 0;
}
.mx_TagPanel_items_selected {
.mx_GroupFilterPanel_items_selected {
cursor: pointer;
}
.mx_TagPanel .mx_TagPanel_divider {
.mx_GroupFilterPanel .mx_GroupFilterPanel_divider {
height: 0px;
width: 90%;
border: none;
border-bottom: 1px solid $tagpanel-divider-color;
border-bottom: 1px solid $groupFilterPanel-divider-color;
}
.mx_TagPanel .mx_TagPanel_scroller {
.mx_GroupFilterPanel .mx_GroupFilterPanel_scroller {
flex-grow: 1;
width: 100%;
}
.mx_TagPanel .mx_TagPanel_tagTileContainer {
.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 6px;
}
.mx_TagPanel .mx_TagPanel_tagTileContainer > div {
.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer > div {
margin: 6px 0;
}
.mx_TagPanel .mx_TagTile {
.mx_GroupFilterPanel .mx_TagTile {
// opacity: 0.5;
position: relative;
}
.mx_TagPanel .mx_TagTile.mx_TagTile_prototype {
.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype {
padding: 3px;
}
.mx_TagPanel .mx_TagTile:focus,
.mx_TagPanel .mx_TagTile:hover,
.mx_TagPanel .mx_TagTile.mx_TagTile_selected {
.mx_GroupFilterPanel .mx_TagTile:focus,
.mx_GroupFilterPanel .mx_TagTile:hover,
.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected {
// opacity: 1;
}
.mx_TagPanel .mx_TagTile.mx_TagTile_selected_prototype {
.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected_prototype {
background-color: $primary-bg-color;
border-radius: 6px;
}
@ -108,7 +108,7 @@ limitations under the License.
}
}
.mx_TagPanel .mx_TagTile_plus {
.mx_GroupFilterPanel .mx_TagTile_plus {
margin-bottom: 12px;
height: 32px;
width: 32px;
@ -132,7 +132,7 @@ limitations under the License.
}
}
.mx_TagPanel .mx_TagTile.mx_TagTile_selected::before {
.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected::before {
content: '';
height: 100%;
background-color: $accent-color;
@ -142,7 +142,7 @@ limitations under the License.
border-radius: 0 3px 3px 0;
}
.mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus {
.mx_GroupFilterPanel .mx_TagTile.mx_AccessibleButton:focus {
filter: none;
}

View file

@ -14,29 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
$tagPanelWidth: 56px; // only applies in this file, used for calculations
$groupFilterPanelWidth: 56px; // only applies in this file, used for calculations
.mx_LeftPanel {
background-color: $roomlist-bg-color;
min-width: 260px;
max-width: 50%;
// Create a row-based flexbox for the TagPanel and the room list
// Create a row-based flexbox for the GroupFilterPanel and the room list
display: flex;
.mx_LeftPanel_tagPanelContainer {
.mx_LeftPanel_GroupFilterPanelContainer {
flex-grow: 0;
flex-shrink: 0;
flex-basis: $tagPanelWidth;
flex-basis: $groupFilterPanelWidth;
height: 100%;
// Create another flexbox so the TagPanel fills the container
// Create another flexbox so the GroupFilterPanel fills the container
display: flex;
// TagPanel handles its own CSS
// GroupFilterPanel handles its own CSS
}
&:not(.mx_LeftPanel_hasTagPanel) {
&:not(.mx_LeftPanel_hasGroupFilterPanel) {
.mx_LeftPanel_roomListContainer {
width: 100%;
}
@ -45,7 +45,7 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations
// Note: The 'room list' in this context is actually everything that isn't the tag
// panel, such as the menu options, breadcrumbs, filtering, etc
.mx_LeftPanel_roomListContainer {
width: calc(100% - $tagPanelWidth);
width: calc(100% - $groupFilterPanelWidth);
background-color: $roomlist-bg-color;
// Create another flexbox (this time a column) for the room list components
@ -169,10 +169,10 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations
min-width: unset;
// We have to forcefully set the width to override the resizer's style attribute.
&.mx_LeftPanel_hasTagPanel {
width: calc(68px + $tagPanelWidth) !important;
&.mx_LeftPanel_hasGroupFilterPanel {
width: calc(68px + $groupFilterPanelWidth) !important;
}
&:not(.mx_LeftPanel_hasTagPanel) {
&:not(.mx_LeftPanel_hasGroupFilterPanel) {
width: 68px !important;
}

View file

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

View file

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

View file

@ -48,7 +48,6 @@ limitations under the License.
white-space: nowrap;
overflow: hidden;
margin: 0 auto;
padding-left: 40px;
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

@ -1,34 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_SetPasswordDialog_change_password input {
border-radius: 3px;
border: 1px solid $input-border-color;
padding: 9px;
color: $primary-fg-color;
background-color: $primary-bg-color;
font-size: $font-15px;
max-width: 280px;
margin-bottom: 10px;
}
.mx_SetPasswordDialog_change_password_button {
margin-top: 68px;
}
.mx_SetPasswordDialog .mx_Dialog_content {
margin-bottom: 0px;
}

View file

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

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;
margin: 12px;
top: 0;
border-radius: 10px;
&::before {
content: "";
@ -55,7 +56,6 @@ limitations under the License.
}
.mx_BaseCard_back {
border-radius: 4px;
left: 0;
&::before {
@ -66,7 +66,6 @@ limitations under the License.
}
.mx_BaseCard_close {
border-radius: 10px;
right: 0;
&::before {

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
$MiniAppTileHeight: 114px;
$MiniAppTileHeight: 200px;
.mx_AppsDrawer {
margin: 5px 5px 5px 18px;
@ -50,10 +50,6 @@ $MiniAppTileHeight: 114px;
}
}
.mx_AppsDrawer_hidden {
display: none;
}
.mx_AppsContainer {
display: flex;
flex-direction: row;
@ -78,21 +74,7 @@ $MiniAppTileHeight: 114px;
font-size: $font-12px;
}
.mx_AddWidget_button_full_width {
max-width: 960px;
}
.mx_SetAppURLDialog_input {
border-radius: 3px;
border: 1px solid $input-border-color;
padding: 9px;
color: $primary-hairline-color;
background-color: $primary-bg-color;
font-size: $font-15px;
}
.mx_AppTile {
max-width: 960px;
width: 50%;
border: 5px solid $widget-menu-bar-bg-color;
border-radius: 4px;
@ -105,7 +87,6 @@ $MiniAppTileHeight: 114px;
}
.mx_AppTileFullWidth {
max-width: 960px;
width: 100%;
margin: 0;
padding: 0;
@ -116,7 +97,6 @@ $MiniAppTileHeight: 114px;
}
.mx_AppTile_mini {
max-width: 960px;
width: 100%;
margin: 0;
padding: 0;
@ -220,9 +200,10 @@ $MiniAppTileHeight: 114px;
}
.mx_AppTileBody_mini {
height: 112px;
height: $MiniAppTileHeight;
width: 100%;
overflow: hidden;
border-radius: 8px;
}
.mx_AppTile .mx_AppTileBody,
@ -248,72 +229,6 @@ $MiniAppTileHeight: 114px;
display: block;
}
.mx_AppTileMenuBarWidgetPadding {
margin-right: 5px;
}
.mx_AppIconTile {
background-color: $lightbox-bg-color;
border: 1px solid rgba(0, 0, 0, 0);
width: 200px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
transition: 0.3s;
border-radius: 3px;
margin: 5px;
display: inline-block;
}
.mx_AppIconTile.mx_AppIconTile_active {
color: $accent-color;
border-color: $accent-color;
}
.mx_AppIconTile:hover {
border: 1px solid $accent-color;
box-shadow: 0 0 10px 5px rgba(200, 200, 200, 0.5);
}
.mx_AppIconTile_content {
padding: 2px 16px;
height: 60px;
overflow: hidden;
}
.mx_AppIconTile_content h4 {
margin-top: 5px;
margin-bottom: 2px;
}
.mx_AppIconTile_content p {
margin-top: 0;
margin-bottom: 5px;
font-size: smaller;
}
.mx_AppIconTile_image {
padding: 10px;
max-width: 100px;
max-height: 100px;
width: auto;
height: auto;
}
.mx_AppIconTile_imageContainer {
text-align: center;
width: 100%;
background-color: white;
border-radius: 3px 3px 0 0;
height: 155px;
display: flex;
justify-content: center;
align-items: center;
}
form.mx_Custom_Widget_Form div {
margin-top: 10px;
margin-bottom: 10px;
}
.mx_AppPermissionWarning {
text-align: center;
background-color: $widget-menu-bar-bg-color;

View file

@ -70,7 +70,7 @@ limitations under the License.
}
.mx_MemberInfo_avatar {
background: $tagpanel-bg-color;
background: $groupFilterPanel-bg-color;
margin-bottom: 16px;
}

View file

@ -96,11 +96,21 @@ limitations under the License.
}
.mx_MemberList_invite span {
background-image: url('$(res)/img/element-icons/room/invite.svg');
background-repeat: no-repeat;
background-position: center left;
background-size: 20px;
padding: 8px 0 8px 25px;
padding: 8px 0;
display: inline-flex;
&::before {
content: '';
display: inline-block;
background-color: $button-fg-color;
mask-image: url('$(res)/img/element-icons/room/invite.svg');
mask-position: center;
mask-repeat: no-repeat;
mask-size: 20px;
width: 20px;
height: 20px;
margin-right: 5px;
}
}
.mx_MemberList_inviteCommunity span {

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

View file

@ -16,6 +16,7 @@ limitations under the License.
.mx_AvatarSetting_avatar {
width: 90px;
min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words
height: 90px;
margin-top: 8px;
position: relative;
@ -84,6 +85,7 @@ limitations under the License.
.mx_AvatarSetting_avatarPlaceholder {
display: block;
height: 90px;
width: inherit;
border-radius: 90px;
cursor: pointer;
}

View file

@ -23,9 +23,16 @@ limitations under the License.
z-index: 100;
box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
cursor: pointer;
// Disable pointer events for Jitsi widgets to function. Direct
// calls have their own cursor and behaviour, but we need to make
// sure the cursor hits the iframe for Jitsi which will be at a
// different level.
pointer-events: none;
.mx_CallPreview {
pointer-events: initial; // restore pointer events so the user can leave/interact
cursor: pointer;
.mx_VideoView {
width: 350px;
}
@ -37,7 +44,7 @@ limitations under the License.
}
.mx_AppTile_persistedWrapper div {
min-width: 300px;
min-width: 350px;
}
.mx_IncomingCallBox {
@ -45,11 +52,14 @@ limitations under the License.
background-color: $primary-bg-color;
padding: 8px;
pointer-events: initial; // restore pointer events so the user can accept/decline
cursor: pointer;
.mx_IncomingCallBox_CallerInfo {
display: flex;
direction: row;
img {
img, .mx_BaseAvatar_initial {
margin: 8px;
}

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">
<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"/>
<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="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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -39,7 +39,7 @@ $info-plinth-fg-color: #888;
$preview-bar-bg-color: $header-panel-bg-color;
$tagpanel-bg-color: rgba(38, 39, 43, 0.82);
$groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82);
$inverted-bg-color: $base-color;
// used by AddressSelector
@ -98,7 +98,7 @@ $roomheader-color: $text-primary-color;
$roomheader-bg-color: $bg-color;
$roomheader-addroom-bg-color: rgba(92, 100, 112, 0.3);
$roomheader-addroom-fg-color: $text-primary-color;
$tagpanel-button-color: $header-panel-text-primary-color;
$groupFilterPanel-button-color: $header-panel-text-primary-color;
$groupheader-button-color: $header-panel-text-primary-color;
$rightpanel-button-color: $header-panel-text-primary-color;
$icon-button-color: #8E99A4;
@ -118,7 +118,7 @@ $roomlist-bg-color: rgba(33, 38, 44, 0.90);
$roomlist-header-color: $tertiary-fg-color;
$roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color;
$groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b;
@ -187,7 +187,7 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: #000000;
$tooltip-timeline-bg-color: $tagpanel-bg-color;
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: $base-color;
@ -202,7 +202,7 @@ $appearance-tab-border-color: $room-highlight-color;
// blur amounts for left left panel (only for element theme, used in _mods.scss)
$roomlist-background-blur-amount: 60px;
$tagpanel-background-blur-amount: 30px;
$groupFilterPanel-background-blur-amount: 30px;
$composer-shadow-color: rgba(0, 0, 0, 0.28);

View file

@ -3,7 +3,7 @@
@import "../../light/css/_fonts.scss";
@import "../../light/css/_light.scss";
// important this goes before _mods,
// as $tagpanel-background-blur-amount and
// as $groupFilterPanel-background-blur-amount and
// $roomlist-background-blur-amount
// are overridden in _dark.scss
@import "_dark.scss";

View file

@ -37,8 +37,8 @@ $info-plinth-fg-color: #888;
$preview-bar-bg-color: $header-panel-bg-color;
$tagpanel-bg-color: $base-color;
$inverted-bg-color: $tagpanel-bg-color;
$groupFilterPanel-bg-color: $base-color;
$inverted-bg-color: $groupFilterPanel-bg-color;
// used by AddressSelector
$selected-color: $room-highlight-color;
@ -95,7 +95,7 @@ $topleftmenu-color: $text-primary-color;
$roomheader-color: $text-primary-color;
$roomheader-addroom-bg-color: #3c4556; // $search-placeholder-color at 0.5 opacity
$roomheader-addroom-fg-color: $text-primary-color;
$tagpanel-button-color: $header-panel-text-primary-color;
$groupFilterPanel-button-color: $header-panel-text-primary-color;
$groupheader-button-color: $header-panel-text-primary-color;
$rightpanel-button-color: $header-panel-text-primary-color;
$icon-button-color: $header-panel-text-primary-color;
@ -115,7 +115,7 @@ $roomlist-bg-color: $header-panel-bg-color;
$roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color;
$groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b;
@ -182,7 +182,7 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: #000000;
$tooltip-timeline-bg-color: $tagpanel-bg-color;
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: $base-color;

View file

@ -67,8 +67,8 @@ $preview-bar-bg-color: #f7f7f7;
$secondary-accent-color: #f2f5f8;
$tertiary-accent-color: #d3efe1;
$tagpanel-bg-color: #27303a;
$inverted-bg-color: $tagpanel-bg-color;
$groupFilterPanel-bg-color: #27303a;
$inverted-bg-color: $groupFilterPanel-bg-color;
// used by RoomDirectory permissions
$plinth-bg-color: $secondary-accent-color;
@ -162,7 +162,7 @@ $roomheader-color: #45474a;
$roomheader-bg-color: $primary-bg-color;
$roomheader-addroom-bg-color: #91a1c0;
$roomheader-addroom-fg-color: $accent-fg-color;
$tagpanel-button-color: #91a1c0;
$groupFilterPanel-button-color: #91a1c0;
$groupheader-button-color: #91a1c0;
$rightpanel-button-color: #91a1c0;
$icon-button-color: #91a1c0;
@ -182,7 +182,7 @@ $roomlist-bg-color: $header-panel-bg-color;
$roomlist-header-color: $primary-fg-color;
$roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color;
$groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b;
@ -305,7 +305,7 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: $reaction-row-button-border-color;
$tooltip-timeline-bg-color: $tagpanel-bg-color;
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: #27303a;

View file

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

View file

@ -62,7 +62,7 @@ $preview-bar-bg-color: #f7f7f7;
$secondary-accent-color: #f2f5f8;
$tertiary-accent-color: #d3efe1;
$tagpanel-bg-color: rgba(232, 232, 232, 0.77);
$groupFilterPanel-bg-color: rgba(232, 232, 232, 0.77);
// used by RoomDirectory permissions
$plinth-bg-color: $secondary-accent-color;
@ -156,7 +156,7 @@ $roomheader-color: #45474a;
$roomheader-bg-color: $primary-bg-color;
$roomheader-addroom-bg-color: rgba(92, 100, 112, 0.2);
$roomheader-addroom-fg-color: #5c6470;
$tagpanel-button-color: #91A1C0;
$groupFilterPanel-button-color: #91A1C0;
$groupheader-button-color: #91A1C0;
$rightpanel-button-color: #91A1C0;
$icon-button-color: #C1C6CD;
@ -176,7 +176,7 @@ $roomlist-bg-color: rgba(245, 245, 245, 0.90);
$roomlist-header-color: $tertiary-fg-color;
$roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color;
$groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b;
@ -320,7 +320,7 @@ $appearance-tab-border-color: $input-darker-bg-color;
// blur amounts for left left panel (only for element theme, used in _mods.scss)
$roomlist-background-blur-amount: 40px;
$tagpanel-background-blur-amount: 20px;
$groupFilterPanel-background-blur-amount: 20px;
$composer-shadow-color: rgba(0, 0, 0, 0.04);

View file

@ -6,14 +6,14 @@
@supports (backdrop-filter: none) {
.mx_LeftPanel {
background-image: var(--avatar-url);
background-image: var(--avatar-url, unset);
background-repeat: no-repeat;
background-size: cover;
background-position: left top;
}
.mx_TagPanel {
backdrop-filter: blur($tagpanel-background-blur-amount);
.mx_GroupFilterPanel {
backdrop-filter: blur($groupFilterPanel-background-blur-amount);
}
.mx_LeftPanel .mx_LeftPanel_roomListContainer {

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import * as ModernizrStatic from "modernizr";
import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg";
@ -31,6 +32,8 @@ import type {Renderer} from "react-dom";
import RightPanelStore from "../stores/RightPanelStore";
import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler";
import {Analytics} from "../Analytics";
import UserActivity from "../UserActivity";
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
declare global {
@ -56,6 +59,8 @@ declare global {
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxCallHandler: CallHandler;
mxAnalytics: Analytics;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import { getCurrentLanguage, _t, _td } from './languageHandler';
import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import Modal from './Modal';
@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password
const hashVarRegex = /#\/(group|room|user)\/.*$/;
// Remove all but the first item in the hash path. Redact unexpected hashes.
function getRedactedHash(hash) {
function getRedactedHash(hash: string): string {
// Don't leak URLs we aren't expecting - they could contain tokens/PII
const match = hashRegex.exec(hash);
if (!match) {
@ -44,7 +44,7 @@ function getRedactedHash(hash) {
// Return the current origin, path and hash separated with a `/`. This does
// not include query parameters.
function getRedactedUrl() {
function getRedactedUrl(): string {
const { origin, hash } = window.location;
let { pathname } = window.location;
@ -56,7 +56,25 @@ function getRedactedUrl() {
return origin + pathname + getRedactedHash(hash);
}
const customVariables = {
interface IData {
/* eslint-disable camelcase */
gt_ms?: string;
e_c?: string;
e_a?: string;
e_n?: string;
e_v?: string;
ping?: string;
/* eslint-enable camelcase */
}
interface IVariable {
id: number;
expl: string; // explanation
example: string; // example value
getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t`
}
const customVariables: Record<string, IVariable> = {
// The Matomo installation at https://matomo.riot.im is currently configured
// with a limit of 10 custom variables.
'App Platform': {
@ -120,7 +138,7 @@ const customVariables = {
},
};
function whitelistRedact(whitelist, str) {
function whitelistRedact(whitelist: string[], str: string): string {
if (whitelist.includes(str)) return str;
return '<redacted>';
}
@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() {
function getUid(): string {
try {
let data = localStorage && localStorage.getItem(UID_KEY);
if (!data && localStorage) {
@ -145,32 +163,36 @@ function getUid() {
const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
class Analytics {
export class Analytics {
private baseUrl: URL = null;
private siteId: string = null;
private visitVariables: Record<number, [string, string]> = {}; // {[id: number]: [name: string, value: string]}
private firstPage = true;
private heartbeatIntervalID: number = null;
private readonly creationTs: string;
private readonly lastVisitTs: string;
private readonly visitCount: string;
constructor() {
this.baseUrl = null;
this.siteId = null;
this.visitVariables = {};
this.firstPage = true;
this._heartbeatIntervalID = null;
this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs && localStorage) {
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime()));
}
this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0;
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0";
this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment
if (localStorage) {
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
localStorage.setItem(VISIT_COUNT_KEY, this.visitCount);
}
}
get disabled() {
public get disabled() {
return !this.baseUrl;
}
canEnable() {
public canEnable() {
const config = SdkConfig.get();
return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId;
}
@ -179,67 +201,67 @@ class Analytics {
* Enable Analytics if initialized but disabled
* otherwise try and initalize, no-op if piwik config missing
*/
async enable() {
public async enable() {
if (!this.disabled) return;
if (!this.canEnable()) return;
const config = SdkConfig.get();
this.baseUrl = new URL("piwik.php", config.piwik.url);
// set constants
this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
this.baseUrl.searchParams.set("apiv", 1); // API version to use
this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF
this.baseUrl.searchParams.set("apiv", "1"); // API version to use
this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF
// set user parameters
this.baseUrl.searchParams.set("_id", getUid()); // uuid
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count
if (this.lastVisitTs) {
this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
}
const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName());
this.setVisitVariable('App Platform', platform.getHumanReadableName());
try {
this._setVisitVariable('App Version', await platform.getAppVersion());
this.setVisitVariable('App Version', await platform.getAppVersion());
} catch (e) {
this._setVisitVariable('App Version', 'unknown');
this.setVisitVariable('App Version', 'unknown');
}
this._setVisitVariable('Chosen Language', getCurrentLanguage());
this.setVisitVariable('Chosen Language', getCurrentLanguage());
const hostname = window.location.hostname;
if (hostname === 'riot.im') {
this._setVisitVariable('Instance', window.location.pathname);
this.setVisitVariable('Instance', window.location.pathname);
} else if (hostname.endsWith('.element.io')) {
this._setVisitVariable('Instance', hostname.replace('.element.io', ''));
this.setVisitVariable('Instance', hostname.replace('.element.io', ''));
}
let installedPWA = "unknown";
try {
// Known to work at least for desktop Chrome
installedPWA = window.matchMedia('(display-mode: standalone)').matches;
installedPWA = String(window.matchMedia('(display-mode: standalone)').matches);
} catch (e) { }
this._setVisitVariable('Installed PWA', installedPWA);
this.setVisitVariable('Installed PWA', installedPWA);
let touchInput = "unknown";
try {
// MDN claims broad support across browsers
touchInput = window.matchMedia('(pointer: coarse)').matches;
touchInput = String(window.matchMedia('(pointer: coarse)').matches);
} catch (e) { }
this._setVisitVariable('Touch Input', touchInput);
this.setVisitVariable('Touch Input', touchInput);
// start heartbeat
this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
}
/**
* Disable Analytics, stop the heartbeat and clear identifiers from localStorage
*/
disable() {
public disable() {
if (this.disabled) return;
this.trackEvent('Analytics', 'opt-out');
window.clearInterval(this._heartbeatIntervalID);
window.clearInterval(this.heartbeatIntervalID);
this.baseUrl = null;
this.visitVariables = {};
localStorage.removeItem(UID_KEY);
@ -248,7 +270,7 @@ class Analytics {
localStorage.removeItem(LAST_VISIT_TS_KEY);
}
async _track(data) {
private async _track(data: IData) {
if (this.disabled) return;
const now = new Date();
@ -264,13 +286,13 @@ class Analytics {
s: now.getSeconds(),
};
const url = new URL(this.baseUrl);
const url = new URL(this.baseUrl.toString()); // copy
for (const key in params) {
url.searchParams.set(key, params[key]);
}
try {
await window.fetch(url, {
await window.fetch(url.toString(), {
method: "GET",
mode: "no-cors",
cache: "no-cache",
@ -281,14 +303,14 @@ class Analytics {
}
}
ping() {
public ping() {
this._track({
ping: 1,
ping: "1",
});
localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts
localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
}
trackPageChange(generationTimeMs) {
public trackPageChange(generationTimeMs?: number) {
if (this.disabled) return;
if (this.firstPage) {
// De-duplicate first page
@ -303,11 +325,11 @@ class Analytics {
}
this._track({
gt_ms: generationTimeMs,
gt_ms: String(generationTimeMs),
});
}
trackEvent(category, action, name, value) {
public trackEvent(category: string, action: string, name?: string, value?: string) {
if (this.disabled) return;
this._track({
e_c: category,
@ -317,12 +339,12 @@ class Analytics {
});
}
_setVisitVariable(key, value) {
private setVisitVariable(key: keyof typeof customVariables, value: string) {
if (this.disabled) return;
this.visitVariables[customVariables[key].id] = [key, value];
}
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
public setLoggedIn(isGuest: boolean, homeserverUrl: string) {
if (this.disabled) return;
const config = SdkConfig.get();
@ -330,16 +352,16 @@ class Analytics {
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
}
setBreadcrumbs(state) {
public setBreadcrumbs(state: boolean) {
if (this.disabled) return;
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
}
showDetailsModal = () => {
public showDetailsModal = () => {
let rows = [];
if (!this.disabled) {
rows = Object.values(this.visitVariables);
@ -360,7 +382,7 @@ class Analytics {
'e.g. <CurrentPageURL>',
{},
{
CurrentPageURL: getRedactedUrl(),
CurrentPageURL: getRedactedUrl,
},
),
},
@ -401,7 +423,7 @@ class Analytics {
};
}
if (!global.mxAnalytics) {
global.mxAnalytics = new Analytics();
if (!window.mxAnalytics) {
window.mxAnalytics = new Analytics();
}
export default global.mxAnalytics;
export default window.mxAnalytics;

View file

@ -14,14 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
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 {MatrixClientPeg} from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
export type ResizeMethod = "crop" | "scale";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(member, width, height, resizeMethod) {
let url;
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
let url: string;
if (member && member.getAvatarUrl) {
url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
@ -41,7 +46,7 @@ export function avatarUrlForMember(member, width, height, resizeMethod) {
return url;
}
export function avatarUrlForUser(user, width, height, resizeMethod) {
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
const url = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio),
@ -54,14 +59,14 @@ export function avatarUrlForUser(user, width, height, resizeMethod) {
return url;
}
function isValidHexColor(color) {
function isValidHexColor(color: string): boolean {
return typeof color === "string" &&
(color.length === 7 || color.lengh === 9) &&
(color.length === 7 || color.length === 9) &&
color.charAt(0) === "#" &&
!color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
}
function urlForColor(color) {
function urlForColor(color: string): string {
const size = 40;
const canvas = document.createElement("canvas");
canvas.width = size;
@ -79,9 +84,10 @@ function urlForColor(color) {
// XXX: Ideally we'd clear this cache when the theme changes
// but since this function is at global scope, it's a bit
// hard to install a listener here, even if there were a clear event to listen to
const colorToDataURLCache = new Map();
const colorToDataURLCache = new Map<string, string>();
export function defaultAvatarUrlForString(s) {
export function defaultAvatarUrlForString(s: string): string {
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
@ -112,7 +118,7 @@ export function defaultAvatarUrlForString(s) {
* @param {string} name
* @return {string} the first letter
*/
export function getInitialLetter(name) {
export function getInitialLetter(name: string): string {
if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied");
@ -145,7 +151,7 @@ export function getInitialLetter(name) {
return firstChar.toUpperCase();
}
export function avatarUrlForRoom(room, width, height, resizeMethod) {
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!room) return null; // null-guard
const explicitRoomAvatar = room.getAvatarUrl(

View file

@ -74,13 +74,32 @@ 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";
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/lib/webrtc/call";
import Analytics from './Analytics';
// until we ts-ify the js-sdk voip code
type Call = any;
enum AudioID {
Ring = 'ringAudio',
Ringback = 'ringbackAudio',
CallEnd = 'callendAudio',
Busy = 'busyAudio',
}
// Unlike 'CallType' in js-sdk, this one includes screen sharing
// (because a screen sharing call is only a screen sharing call to the caller,
// to the callee it's just a video call, at least as far as the current impl
// is concerned).
export enum PlaceCallType {
Voice = 'voice',
Video = 'video',
ScreenSharing = 'screensharing',
}
export default class CallHandler {
private calls = new Map<string, Call>();
private audioPromises = new Map<string, Promise<void>>();
private calls = new Map<string, MatrixCall>();
private audioPromises = new Map<AudioID, Promise<void>>();
static sharedInstance() {
if (!window.mxCallHandler) {
@ -105,22 +124,20 @@ export default class CallHandler {
}
}
getCallForRoom(roomId: string): Call {
getCallForRoom(roomId: string): MatrixCall {
return this.calls.get(roomId) || null;
}
getAnyActiveCall() {
const roomsWithCalls = Object.keys(this.calls);
for (let i = 0; i < roomsWithCalls.length; i++) {
if (this.calls.get(roomsWithCalls[i]) &&
this.calls.get(roomsWithCalls[i]).call_state !== "ended") {
return this.calls.get(roomsWithCalls[i]);
for (const call of this.calls.values()) {
if (call.state !== CallState.Ended) {
return call;
}
}
return null;
}
play(audioId: string) {
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -149,7 +166,7 @@ export default class CallHandler {
}
}
pause(audioId: string) {
pause(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -163,8 +180,19 @@ export default class CallHandler {
}
}
private setCallListeners(call: Call) {
call.on("error", (err) => {
private matchesCallForThisRoom(call: MatrixCall) {
// We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room.
const callForThisRoom = this.getCallForRoom(call.roomId);
return callForThisRoom && call.callId === callForThisRoom.callId;
}
private setCallListeners(call: MatrixCall) {
call.on(CallEvent.Error, (err) => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callError', 'error', err);
console.error("Call error:", err);
if (
MatrixClientPeg.get().getTurnServers().length === 0 &&
@ -179,68 +207,99 @@ export default class CallHandler {
description: err.message,
});
});
call.on("hangup", () => {
this.setCallState(undefined, call.roomId, "ended");
call.on(CallEvent.Hangup, () => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callHangup');
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.setCallState(undefined, call.roomId, "ended");
this.pause("ringbackAudio");
this.play("callendAudio");
} else if (newState === "ended" && oldState === "invite_sent" &&
(call.hangupParty === "remote" ||
(call.hangupParty === "local" && call.hangupReason === "invite_timeout")
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
if (!this.matchesCallForThisRoom(call)) return;
this.setCallState(call, newState);
switch (oldState) {
case CallState.Ringing:
this.pause(AudioID.Ring);
break;
case CallState.InviteSent:
this.pause(AudioID.Ringback);
break;
}
switch (newState) {
case CallState.Ringing:
this.play(AudioID.Ring);
break;
case CallState.InviteSent:
this.play(AudioID.Ringback);
break;
case CallState.Ended:
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
this.removeCallForRoom(call.roomId);
if (oldState === CallState.InviteSent && (
call.hangupParty === CallParty.Remote ||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
)) {
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");
this.play(AudioID.Busy);
let title;
let description;
if (call.hangupReason === CallErrorCode.UserHangup) {
title = _t("Call Declined");
description = _t("The other party declined the call.");
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
title = _t("Call Failed");
// XXX: full stop appended as some relic here, but these
// strings need proper input from design anyway, so let's
// not change this string until we have a proper one.
description = _t('The remote side failed to pick up') + '.';
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
}
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title, description,
});
} else {
this.play(AudioID.CallEnd);
}
}
});
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
if (call.state === CallState.Ringing) {
this.pause(AudioID.Ring);
} else if (call.state === CallState.InviteSent) {
this.pause(AudioID.Ringback);
}
this.calls.set(newCall.roomId, newCall);
this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state);
});
}
private setCallState(call: Call, roomId: string, status: string) {
private setCallState(call: MatrixCall, status: CallState) {
console.log(
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
`Call state in ${call.roomId} changed to ${status}`,
);
this.calls.set(roomId, call);
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,
room_id: call.roomId,
state: status,
});
}
private removeCallForRoom(roomId: string) {
this.calls.delete(roomId);
}
private showICEFallbackPrompt() {
const cli = MatrixClientPeg.get();
const code = sub => <code>{sub}</code>;
@ -270,20 +329,26 @@ export default class CallHandler {
}, 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,
private placeCall(
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId);
this.calls.set(roomId, call);
this.setCallListeners(call);
if (type === PlaceCallType.Voice) {
call.placeVoiceCall();
} else if (type === 'video') {
call.placeVideoCall(
remoteElement,
localElement,
);
} else if (payload.type === 'screensharing') {
} else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
this.setCallState(undefined, newCall.roomId, "ended");
this.removeCallForRoom(roomId);
console.log("Can't capture screen: " + screenCapErrorString);
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
@ -291,15 +356,13 @@ export default class CallHandler {
});
return;
}
newCall.placeScreenSharingCall(
payload.remote_element,
payload.local_element,
);
call.placeScreenSharingCall(remoteElement, localElement);
} else {
console.error("Unknown conf call type: %s", payload.type);
console.error("Unknown conf call type: %s", type);
}
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'place_call':
{
@ -334,8 +397,8 @@ export default class CallHandler {
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);
this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
@ -349,8 +412,17 @@ export default class CallHandler {
break;
case 'place_conference_call':
console.info("Place conference call in %s", payload.room_id);
Analytics.trackEvent('voip', 'placeConferenceCall');
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()) {
@ -366,24 +438,29 @@ export default class CallHandler {
return;
}
const call = payload.call;
const call = payload.call as MatrixCall;
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(call.roomId, call)
this.setCallListeners(call);
this.setCallState(call, call.roomId, "ringing");
}
break;
case 'hangup':
case 'reject':
if (!this.calls.get(payload.room_id)) {
return; // no call to hangup
}
this.calls.get(payload.room_id).hangup();
this.setCallState(null, payload.room_id, "ended");
if (payload.action === 'reject') {
this.calls.get(payload.room_id).reject();
} else {
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
}
this.removeCallForRoom(payload.room_id);
break;
case 'answer':
if (!this.calls.get(payload.room_id)) {
if (!this.calls.has(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,
@ -398,10 +475,12 @@ export default class CallHandler {
show: true,
});
// prevent double clicking the call button
const room = MatrixClientPeg.get().getRoom(roomId);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, 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!'),
@ -409,33 +488,6 @@ export default class CallHandler {
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;
@ -484,4 +536,38 @@ export default class CallHandler {
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

@ -17,7 +17,6 @@ limitations under the License.
*/
import React from "react";
import extend from './extend';
import dis from './dispatcher/dispatcher';
import {MatrixClientPeg} from './MatrixClientPeg';
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -497,7 +496,7 @@ export default class ContentMessages {
if (file.type.indexOf('image/') === 0) {
content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
extend(content.info, imageInfo);
Object.assign(content.info, imageInfo);
resolve();
}, (e) => {
console.error(e);
@ -510,7 +509,7 @@ export default class ContentMessages {
} else if (file.type.indexOf('video/') === 0) {
content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
extend(content.info, videoInfo);
Object.assign(content.info, videoInfo);
resolve();
}, (e) => {
content.msgtype = 'm.file';

View file

@ -17,7 +17,7 @@ limitations under the License.
import { _t } from './languageHandler';
function getDaysArray() {
function getDaysArray(): string[] {
return [
_t('Sun'),
_t('Mon'),
@ -29,7 +29,7 @@ function getDaysArray() {
];
}
function getMonthsArray() {
function getMonthsArray(): string[] {
return [
_t('Jan'),
_t('Feb'),
@ -46,11 +46,11 @@ function getMonthsArray() {
];
}
function pad(n) {
function pad(n: number): string {
return (n < 10 ? '0' : '') + n;
}
function twelveHourTime(date, showSeconds=false) {
function twelveHourTime(date: Date, showSeconds = false): string {
let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) {
return `${hours}:${minutes}${ampm}`;
}
export function formatDate(date, showTwelveHour=false) {
export function formatDate(date: Date, showTwelveHour = false): string {
const now = new Date();
const days = getDaysArray();
const months = getMonthsArray();
@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) {
return formatFullDate(date, showTwelveHour);
}
export function formatFullDateNoTime(date) {
export function formatFullDateNoTime(date: Date): string {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) {
});
}
export function formatFullDate(date, showTwelveHour=false) {
export function formatFullDate(date: Date, showTwelveHour = false): string {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
@ -109,14 +109,14 @@ export function formatFullDate(date, showTwelveHour=false) {
});
}
export function formatFullTime(date, showTwelveHour=false) {
export function formatFullTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) {
return twelveHourTime(date, true);
}
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
}
export function formatTime(date, showTwelveHour=false) {
export function formatTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) {
return twelveHourTime(date);
}
@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) {
}
const MILLIS_IN_DAY = 86400000;
export function wantsDateSeparator(prevEventDate, nextEventDate) {
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
if (!nextEventDate || !prevEventDate) {
return false;
}

View file

@ -1,285 +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, KnownWidgetActions} from "./widgets/WidgetApi";
import {objectClone} from "./utils/objects";
import {ModalWidgetStore} from "./stores/ModalWidgetStore";
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' || action === KnownWidgetActions.CloseModalWidget) {
// Handled by caller
this.sendResponse(event, {}); // ack
} else if (action === KnownWidgetActions.OpenModalWidget) {
if (ModalWidgetStore.instance.canOpenModalWidget()) {
ModalWidgetStore.instance.openModalWidget(event.data.data, widgetId);
this.sendResponse(event, {}); // ack
} else {
this.sendError(event, {message: 'Unable to open modal at this time'}); // nak
}
} 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;
if (!event.source) return; // source may have gone away since
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

@ -53,7 +53,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
/*
* Return true if the given string contains emoji

View file

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

View file

@ -18,35 +18,73 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
import Matrix from "matrix-js-sdk";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
interface ILoginOptions {
defaultDeviceDisplayName?: string;
}
// TODO: Move this to JS SDK
interface ILoginFlow {
type: string;
}
// TODO: Move this to JS SDK
/* eslint-disable camelcase */
interface ILoginParams {
identifier?: string;
password?: string;
token?: string;
device_id?: string;
initial_device_display_name?: string;
}
/* eslint-enable camelcase */
export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
this._hsUrl = hsUrl;
this._isUrl = isUrl;
this._fallbackHsUrl = fallbackHsUrl;
this._currentFlowIndex = 0;
this._flows = [];
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this._tempClient = null; // memoize
private hsUrl: string;
private isUrl: string;
private fallbackHsUrl: string;
private currentFlowIndex: number;
// TODO: Flows need a type in JS SDK
private flows: Array<ILoginFlow>;
private defaultDeviceDisplayName: string;
private tempClient: MatrixClient;
constructor(
hsUrl: string,
isUrl: string,
fallbackHsUrl?: string,
opts?: ILoginOptions,
) {
this.hsUrl = hsUrl;
this.isUrl = isUrl;
this.fallbackHsUrl = fallbackHsUrl;
this.currentFlowIndex = 0;
this.flows = [];
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this.tempClient = null; // memoize
}
getHomeserverUrl() {
return this._hsUrl;
public getHomeserverUrl(): string {
return this.hsUrl;
}
getIdentityServerUrl() {
return this._isUrl;
public getIdentityServerUrl(): string {
return this.isUrl;
}
setHomeserverUrl(hsUrl) {
this._tempClient = null; // clear memoization
this._hsUrl = hsUrl;
public setHomeserverUrl(hsUrl: string): void {
this.tempClient = null; // clear memoization
this.hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._tempClient = null; // clear memoization
this._isUrl = isUrl;
public setIdentityServerUrl(isUrl: string): void {
this.tempClient = null; // clear memoization
this.isUrl = isUrl;
}
/**
@ -54,40 +92,41 @@ export default class Login {
* requests.
* @returns {MatrixClient}
*/
createTemporaryClient() {
if (this._tempClient) return this._tempClient; // use memoization
return this._tempClient = Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
public createTemporaryClient(): MatrixClient {
if (this.tempClient) return this.tempClient; // use memoization
return this.tempClient = Matrix.createClient({
baseUrl: this.hsUrl,
idBaseUrl: this.isUrl,
});
}
getFlows() {
const self = this;
public async getFlows(): Promise<Array<ILoginFlow>> {
const client = this.createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
const { flows } = await client.loginFlows();
this.flows = flows;
this.currentFlowIndex = 0;
// technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here.
return self._flows;
});
return this.flows;
}
chooseFlow(flowIndex) {
this._currentFlowIndex = flowIndex;
public chooseFlow(flowIndex): void {
this.currentFlowIndex = flowIndex;
}
getCurrentFlowStep() {
public getCurrentFlowStep(): string {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
const flowStep = this._flows[this._currentFlowIndex];
const flowStep = this.flows[this.currentFlowIndex];
return flowStep ? flowStep.type : null;
}
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
const self = this;
public loginViaPassword(
username: string,
phoneCountry: string,
phoneNumber: string,
password: string,
): Promise<IMatrixClientCreds> {
const isEmail = username.indexOf("@") > 0;
let identifier;
@ -113,14 +152,14 @@ export default class Login {
}
const loginParams = {
password: pass,
identifier: identifier,
initial_device_display_name: this._defaultDeviceDisplayName,
password,
identifier,
initial_device_display_name: this.defaultDeviceDisplayName,
};
const tryFallbackHs = (originalError) => {
return sendLoginRequest(
self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams,
this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((fallbackError) => {
console.log("fallback HS login failed", fallbackError);
// throw the original error
@ -130,11 +169,11 @@ export default class Login {
let originalLoginError = null;
return sendLoginRequest(
self._hsUrl, self._isUrl, 'm.login.password', loginParams,
this.hsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((error) => {
originalLoginError = error;
if (error.httpStatus === 403) {
if (self._fallbackHsUrl) {
if (this.fallbackHsUrl) {
return tryFallbackHs(originalLoginError);
}
}
@ -154,11 +193,16 @@ export default class Login {
* @param {string} hsUrl the base url of the Homeserver used to log in.
* @param {string} isUrl the base url of the default identity server
* @param {string} loginType the type of login to do
* @param {object} loginParams the parameters for the login
* @param {ILoginParams} loginParams the parameters for the login
*
* @returns {MatrixClientCreds}
*/
export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) {
export async function sendLoginRequest(
hsUrl: string,
isUrl: string,
loginType: string,
loginParams: ILoginParams,
): Promise<IMatrixClientCreds> {
const client = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
@ -179,11 +223,15 @@ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) {
}
}
return {
const creds: IMatrixClientCreds = {
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
};
SecurityCustomisations.examineLoginResponse?.(data, creds);
return creds;
}

View file

@ -17,6 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
import {MatrixClient} from 'matrix-js-sdk/src/client';
import {MemoryStore} from 'matrix-js-sdk/src/store/memory';
import * as utils from 'matrix-js-sdk/src/utils';
@ -31,17 +32,18 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks } from './SecurityManager';
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
export interface IMatrixClientCreds {
homeserverUrl: string;
identityServerUrl: string;
userId: string;
deviceId: string;
deviceId?: string;
accessToken: string;
guest: boolean;
guest?: boolean;
pickleKey?: string;
freshLogin?: boolean;
}
// TODO: Move this to the js-sdk
@ -192,6 +194,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
);
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
StorageManager.setCryptoInitialised(true);
}
} catch (e) {
@ -247,8 +250,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
private createClient(creds: IMatrixClientCreds): void {
// TODO: Make these opts typesafe with the js-sdk
const opts = {
const opts: ICreateClientOpts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,

View file

@ -132,7 +132,7 @@ export class ModalManager {
public createTrackedDialogAsync<T extends any[]>(
analyticsAction: string,
analyticsInfo: string,
...rest: Parameters<ModalManager["appendDialogAsync"]>
...rest: Parameters<ModalManager["createDialogAsync"]>
) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialogAsync<T>(...rest);

View file

@ -218,7 +218,7 @@ export const Notifier = {
// calculated value. It is determined based upon whether or not the master rule is enabled
// and other flags. Setting it here would cause a circular reference.
Analytics.trackEvent('Notifier', 'Set Enabled', enable);
Analytics.trackEvent('Notifier', 'Set Enabled', String(enable));
// make sure that we persist the current setting audio_enabled setting
// before changing anything
@ -287,7 +287,7 @@ export const Notifier = {
setPromptHidden: function(hidden: boolean, persistent = true) {
this.toolbarHidden = hidden;
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden);
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden));
hideNotificationsToast();

View file

@ -19,30 +19,34 @@ limitations under the License.
import {MatrixClientPeg} from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
import Timer from './utils/Timer';
import {ActionPayload} from "./dispatcher/payloads";
// Time in ms after that a user is considered as unavailable/away
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
const PRESENCE_STATES = ["online", "offline", "unavailable"];
enum State {
Online = "online",
Offline = "offline",
Unavailable = "unavailable",
}
class Presence {
constructor() {
this._activitySignal = null;
this._unavailableTimer = null;
this._onAction = this._onAction.bind(this);
this._dispatcherRef = null;
}
private unavailableTimer: Timer = null;
private dispatcherRef: string = null;
private state: State = null;
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the homeserver.
*/
async start() {
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
public async start() {
this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
// the user_activity_start action starts the timer
this._dispatcherRef = dis.register(this._onAction);
while (this._unavailableTimer) {
this.dispatcherRef = dis.register(this.onAction);
while (this.unavailableTimer) {
try {
await this._unavailableTimer.finished();
this.setState("unavailable");
await this.unavailableTimer.finished();
this.setState(State.Unavailable);
} catch (e) { /* aborted, stop got called */ }
}
}
@ -50,14 +54,14 @@ class Presence {
/**
* Stop tracking user activity
*/
stop() {
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
public stop() {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
if (this._unavailableTimer) {
this._unavailableTimer.abort();
this._unavailableTimer = null;
if (this.unavailableTimer) {
this.unavailableTimer.abort();
this.unavailableTimer = null;
}
}
@ -65,14 +69,14 @@ class Presence {
* Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum)
*/
getState() {
public getState() {
return this.state;
}
_onAction(payload) {
private onAction = (payload: ActionPayload) => {
if (payload.action === 'user_activity') {
this.setState("online");
this._unavailableTimer.restart();
this.setState(State.Online);
this.unavailableTimer.restart();
}
}
@ -81,13 +85,11 @@ class Presence {
* If the state has changed, the homeserver will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
async setState(newState) {
private async setState(newState: State) {
if (newState === this.state) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
throw new Error("Bad presence state: " + newState);
}
const oldState = this.state;
this.state = newState;

View file

@ -24,7 +24,6 @@ import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
// import {MatrixClientPeg} from './MatrixClientPeg';
// Regex for what a "safe" or "Matrix-looking" localpart would be.
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -44,29 +43,6 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
*/
export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {};
// look for an ILAG compatible flow. We define this as one
// which has only dummy or recaptcha flows. In practice it
// would support any stage InteractiveAuth supports, just not
// ones like email & msisdn which require the user to supply
// the relevant details in advance. We err on the side of
// caution though.
// XXX: ILAG is disabled for now,
// see https://github.com/vector-im/element-web/issues/8222
// const flows = await _getRegistrationFlows();
// const hasIlagFlow = flows.some((flow) => {
// return flow.stages.every((stage) => {
// return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage);
// });
// });
// if (hasIlagFlow) {
// dis.dispatch({
// action: 'view_set_mxid',
// go_home_on_cancel: options.go_home_on_cancel,
// });
//} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true,
@ -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

@ -13,9 +13,10 @@ 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 { _t } from './languageHandler';
export function levelRoleMap(usersDefault) {
export function levelRoleMap(usersDefault: number) {
return {
undefined: _t('Default'),
0: _t('Restricted'),
@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) {
};
}
export function textualPowerLevel(level, usersDefault) {
export function textualPowerLevel(level: number, usersDefault: number): string {
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level];

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import Modal from './Modal';
import * as sdk from './index';
import {MatrixClientPeg} from './MatrixClientPeg';
@ -24,16 +26,26 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
import { isSecureBackupRequired } from './utils/WellKnownUtils';
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security";
// 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
// during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the
// operation ends.
let secretStorageKeys = {};
let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, ISecretStorageKeyInfo> = {};
let secretStorageBeingAccessed = false;
function isCachingAllowed() {
let nonInteractive = false;
let dehydrationCache: {
key?: Uint8Array,
keyInfo?: ISecretStorageKeyInfo,
} = {};
function isCachingAllowed(): boolean {
return secretStorageBeingAccessed;
}
@ -44,7 +56,7 @@ function isCachingAllowed() {
*
* @returns {bool}
*/
export function isSecretStorageBeingAccessed() {
export function isSecretStorageBeingAccessed(): boolean {
return secretStorageBeingAccessed;
}
@ -54,7 +66,7 @@ export class AccessCancelledError extends Error {
}
}
async function confirmToDismiss() {
async function confirmToDismiss(): Promise<boolean> {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
@ -66,7 +78,26 @@ async function confirmToDismiss() {
return !sure;
}
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
function makeInputToKey(
keyInfo: ISecretStorageKeyInfo,
): (keyParams: { passphrase: string, recoveryKey: string }) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }) => {
if (passphrase) {
return deriveKey(
passphrase,
keyInfo.passphrase.salt,
keyInfo.passphrase.iterations,
);
} else {
return decodeRecoveryKey(recoveryKey);
}
};
}
async function getSecretStorageKey(
{ keys: keyInfos }: { keys: Record<string, ISecretStorageKeyInfo> },
ssssItemName,
): Promise<[string, Uint8Array]> {
const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) {
throw new Error("Multiple storage key requests not implemented");
@ -78,17 +109,25 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
return [keyId, secretStorageKeys[keyId]];
}
const inputToKey = async ({ passphrase, recoveryKey }) => {
if (passphrase) {
return deriveKey(
passphrase,
keyInfo.passphrase.salt,
keyInfo.passphrase.iterations,
);
} else {
return decodeRecoveryKey(recoveryKey);
if (dehydrationCache.key) {
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
return [keyId, dehydrationCache.key];
}
};
}
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Using key from security customisations (secret storage)")
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
return [keyId, keyFromCustomisations];
}
if (nonInteractive) {
throw new Error("Could not unlock non-interactively");
}
const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
AccessSecretStorageDialog,
/* props= */
@ -118,24 +157,79 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session
cacheSecretStorageKey(keyId, key);
cacheSecretStorageKey(keyId, keyInfo, key);
return [keyId, key];
}
function cacheSecretStorageKey(keyId, key) {
export async function getDehydrationKey(
keyInfo: ISecretStorageKeyInfo,
checkFunc: (Uint8Array) => void,
): Promise<Uint8Array> {
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Using key from security customisations (dehydration)")
return keyFromCustomisations;
}
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: string,
keyInfo: ISecretStorageKeyInfo,
key: Uint8Array,
): void {
if (isCachingAllowed()) {
secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
}
}
const onSecretRequested = async function({
user_id: userId,
device_id: deviceId,
request_id: requestId,
name,
device_trust: deviceTrust,
}) {
async function onSecretRequested(
userId: string,
deviceId: string,
requestId: string,
name: string,
deviceTrust: IDeviceTrustLevel,
): Promise<string> {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get();
if (userId !== client.getUserId()) {
@ -170,15 +264,16 @@ const onSecretRequested = async function({
return key && encodeBase64(key);
}
console.warn("onSecretRequested didn't recognise the secret named ", name);
};
}
export const crossSigningCallbacks = {
export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey,
cacheSecretStorageKey,
onSecretRequested,
getDehydrationKey,
};
export async function promptForBackupPassphrase() {
export async function promptForBackupPassphrase(): Promise<Uint8Array> {
let key;
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
@ -228,7 +323,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
/* priority = */ false,
/* static = */ true,
/* options = */ {
onBeforeClose(reason) {
onBeforeClose: async (reason) => {
// If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") {
return !isSecureBackupRequired();
@ -262,16 +357,86 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
await cli.bootstrapSecretStorage({
getKeyBackupPassphrase: promptForBackupPassphrase,
});
const keyId = Object.keys(secretStorageKeys)[0];
if (keyId && SettingsStore.getValue("feature_dehydration")) {
let dehydrationKeyInfo = {};
if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) {
dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase };
}
console.log("Setting dehydration key");
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
} else if (!keyId) {
console.warn("Not setting dehydration key: no SSSS key found");
} else {
console.log("Not setting dehydration key: feature disabled");
}
}
// `return await` needed here to ensure `finally` block runs after the
// inner operation completes.
return await func();
} catch (e) {
SecurityCustomisations.catchAccessSecretStorageError?.(e);
console.error(e);
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
}
}
// FIXME: this function name is a bit of a mouthful
export async function tryToUnlockSecretStorageWithDehydrationKey(
client: MatrixClient,
): Promise<void> {
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
let dehydrationKeyInfo = {};
if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) {
dehydrationKeyInfo = { 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 {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {SerializedPart} from "./editor/parts";
import EditorModel from "./editor/model";
interface IHistoryItem {
parts: SerializedPart[];
replyEventId?: string;
}
export default class SendHistoryManager {
history: Array<HistoryItem> = [];
history: Array<IHistoryItem> = [];
prefix: string;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array
lastIndex = 0; // used for indexing the storage
currentIndex = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string) {
this.prefix = prefix + roomId;
@ -32,8 +41,7 @@ export default class SendHistoryManager {
while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) {
try {
const serializedParts = JSON.parse(itemJSON);
this.history.push(serializedParts);
this.history.push(JSON.parse(itemJSON));
} catch (e) {
console.warn("Throwing away unserialisable history", e);
break;
@ -45,15 +53,22 @@ export default class SendHistoryManager {
this.currentIndex = this.lastIndex + 1;
}
save(editorModel: Object) {
const serializedParts = editorModel.serializeParts();
this.history.push(serializedParts);
this.currentIndex = this.history.length;
this.lastIndex += 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts));
static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem {
return {
parts: model.serializeParts(),
replyEventId: replyEvent ? replyEvent.getId() : undefined,
};
}
getItem(offset: number): ?HistoryItem {
save(editorModel: EditorModel, replyEvent?: MatrixEvent) {
const item = SendHistoryManager.createItem(editorModel, replyEvent);
this.history.push(item);
this.currentIndex = this.history.length;
this.lastIndex += 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item));
}
getItem(offset: number): IHistoryItem {
this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}

View file

@ -18,7 +18,6 @@ import { _t } from './languageHandler';
import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import {WidgetType} from "./widgets/WidgetType";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
function textForMemberEvent(ev) {
@ -199,59 +198,30 @@ function textForRelatedGroupsEvent(ev) {
function textForServerACLEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent();
const changes = [];
const current = ev.getContent();
const prev = {
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
allow_ip_literals: !(prevContent.allow_ip_literals === false),
};
let text = "";
if (prev.deny.length === 0 && prev.allow.length === 0) {
text = `${senderDisplayName} set server ACLs for this room: `;
text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName});
} else {
text = `${senderDisplayName} changed the server ACLs for this room: `;
text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName});
}
if (!Array.isArray(current.allow)) {
current.allow = [];
}
/* If we know for sure everyone is banned, don't bother showing the diff view */
// If we know for sure everyone is banned, mark the room as obliterated
if (current.allow.length === 0) {
return text + "🎉 All servers are banned from participating! This room can no longer be used.";
return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used.");
}
if (!Array.isArray(current.deny)) {
current.deny = [];
}
const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv));
const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv));
const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv));
const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv));
if (bannedServers.length > 0) {
changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`);
}
if (unbannedServers.length > 0) {
changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`);
}
if (allowedServers.length > 0) {
changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`);
}
if (unallowedServers.length > 0) {
changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`);
}
if (prev.allow_ip_literals !== current.allow_ip_literals) {
const allowban = current.allow_ip_literals ? "allowed" : "banned";
changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`);
}
return text + changes.join(" ");
return text;
}
function textForMessageEvent(ev) {
@ -330,14 +300,27 @@ function textForCallHangupEvent(event) {
reason = _t('(not supported by this browser)');
} else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") {
// We couldn't establish a connection at all
reason = _t('(could not connect media)');
} else if (eventContent.reason === "ice_timeout") {
// We established a connection but it died
reason = _t('(connection failed)');
} else if (eventContent.reason === "user_media_failed") {
// The other side couldn't open capture devices
reason = _t("(their device couldn't start the camera / microphone)");
} else if (eventContent.reason === "unknown_error") {
// An error code the other side doesn't have a way to express
// (as opposed to an error code they gave but we don't know about,
// in which case we show the error code)
reason = _t("(an error occurred)");
} else if (eventContent.reason === "invite_timeout") {
reason = _t('(no answer)');
} else if (eventContent.reason === "user hangup") {
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
// workaround for https://github.com/vector-im/element-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623
// Also the correct hangup code as of VoIP v1 (with underscore)
reason = '';
} else {
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
@ -346,6 +329,11 @@ function textForCallHangupEvent(event) {
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
}
function textForCallRejectEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', {senderName});
}
function textForCallInviteEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
@ -464,10 +452,6 @@ function textForWidgetEvent(event) {
const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
const {name, type, url} = event.getContent() || {};
if (WidgetType.JITSI.matches(type) || WidgetType.JITSI.matches(prevType)) {
return textForJitsiWidgetEvent(event, senderName, url, prevUrl);
}
let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name
if (widgetName && widgetName.length > 0) {
@ -493,24 +477,6 @@ function textForWidgetEvent(event) {
}
}
function textForJitsiWidgetEvent(event, senderName, url, prevUrl) {
if (url) {
if (prevUrl) {
return _t('Group call modified by %(senderName)s', {
senderName,
});
} else {
return _t('Group call started by %(senderName)s', {
senderName,
});
}
} else {
return _t('Group call ended by %(senderName)s', {
senderName,
});
}
}
function textForMjolnirEvent(event) {
const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent();
@ -597,6 +563,7 @@ const handlers = {
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
'm.call.reject': textForCallRejectEvent,
};
const stateHandlers = {

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

@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
* see doc on the userActive* functions for what these mean.
*/
export default class UserActivity {
constructor(windowObj, documentObj) {
this._window = windowObj;
this._document = documentObj;
private readonly activeNowTimeout: Timer;
private readonly activeRecentlyTimeout: Timer;
private attachedActiveNowTimers: Timer[] = [];
private attachedActiveRecentlyTimers: Timer[] = [];
private lastScreenX = 0;
private lastScreenY = 0;
this._attachedActiveNowTimers = [];
this._attachedActiveRecentlyTimers = [];
this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
this._onUserActivity = this._onUserActivity.bind(this);
this._onWindowBlurred = this._onWindowBlurred.bind(this);
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
this.lastScreenX = 0;
this.lastScreenY = 0;
constructor(private readonly window: Window, private readonly document: Document) {
this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
}
static sharedInstance() {
if (global.mxUserActivity === undefined) {
global.mxUserActivity = new UserActivity(window, document);
if (window.mxUserActivity === undefined) {
window.mxUserActivity = new UserActivity(window, document);
}
return global.mxUserActivity;
return window.mxUserActivity;
}
/**
@ -69,8 +66,8 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
timeWhileActiveNow(timer) {
this._timeWhile(timer, this._attachedActiveNowTimers);
public timeWhileActiveNow(timer: Timer) {
this.timeWhile(timer, this.attachedActiveNowTimers);
if (this.userActiveNow()) {
timer.start();
}
@ -85,14 +82,14 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
timeWhileActiveRecently(timer) {
this._timeWhile(timer, this._attachedActiveRecentlyTimers);
public timeWhileActiveRecently(timer: Timer) {
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
if (this.userActiveRecently()) {
timer.start();
}
}
_timeWhile(timer, attachedTimers) {
private timeWhile(timer: Timer, attachedTimers: Timer[]) {
// important this happens first
const index = attachedTimers.indexOf(timer);
if (index === -1) {
@ -112,36 +109,36 @@ export default class UserActivity {
/**
* Start listening to user activity
*/
start() {
this._document.addEventListener('mousedown', this._onUserActivity);
this._document.addEventListener('mousemove', this._onUserActivity);
this._document.addEventListener('keydown', this._onUserActivity);
this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.addEventListener("blur", this._onWindowBlurred);
this._window.addEventListener("focus", this._onUserActivity);
public start() {
this.document.addEventListener('mousedown', this.onUserActivity);
this.document.addEventListener('mousemove', this.onUserActivity);
this.document.addEventListener('keydown', this.onUserActivity);
this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.addEventListener("blur", this.onWindowBlurred);
this.window.addEventListener("focus", this.onUserActivity);
// can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
this._window.addEventListener('wheel', this._onUserActivity, {
passive: true, capture: true,
this.window.addEventListener('wheel', this.onUserActivity, {
passive: true,
capture: true,
});
}
/**
* Stop tracking user activity
*/
stop() {
this._document.removeEventListener('mousedown', this._onUserActivity);
this._document.removeEventListener('mousemove', this._onUserActivity);
this._document.removeEventListener('keydown', this._onUserActivity);
this._window.removeEventListener('wheel', this._onUserActivity, {
passive: true, capture: true,
public stop() {
this.document.removeEventListener('mousedown', this.onUserActivity);
this.document.removeEventListener('mousemove', this.onUserActivity);
this.document.removeEventListener('keydown', this.onUserActivity);
this.window.removeEventListener('wheel', this.onUserActivity, {
capture: true,
});
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.removeEventListener("blur", this._onWindowBlurred);
this._window.removeEventListener("focus", this._onUserActivity);
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.removeEventListener("blur", this.onWindowBlurred);
this.window.removeEventListener("focus", this.onUserActivity);
}
/**
@ -151,8 +148,8 @@ export default class UserActivity {
* user's attention at any given moment.
* @returns {boolean} true if user is currently 'active'
*/
userActiveNow() {
return this._activeNowTimeout.isRunning();
public userActiveNow() {
return this.activeNowTimeout.isRunning();
}
/**
@ -163,27 +160,27 @@ export default class UserActivity {
* (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently
*/
userActiveRecently() {
return this._activeRecentlyTimeout.isRunning();
public userActiveRecently() {
return this.activeRecentlyTimeout.isRunning();
}
_onPageVisibilityChanged(e) {
if (this._document.visibilityState === "hidden") {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
private onPageVisibilityChanged = e => {
if (this.document.visibilityState === "hidden") {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
} else {
this._onUserActivity(e);
}
this.onUserActivity(e);
}
};
_onWindowBlurred() {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
}
private onWindowBlurred = () => {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
};
_onUserActivity(event) {
private onUserActivity = (event: MouseEvent) => {
// ignore anything if the window isn't focused
if (!this._document.hasFocus()) return;
if (!this.document.hasFocus()) return;
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
@ -195,25 +192,25 @@ export default class UserActivity {
}
dis.dispatch({action: 'user_activity'});
if (!this._activeNowTimeout.isRunning()) {
this._activeNowTimeout.start();
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.start();
dis.dispatch({action: 'user_activity_start'});
this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout);
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else {
this._activeNowTimeout.restart();
this.activeNowTimeout.restart();
}
if (!this._activeRecentlyTimeout.isRunning()) {
this._activeRecentlyTimeout.start();
if (!this.activeRecentlyTimeout.isRunning()) {
this.activeRecentlyTimeout.start();
this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout);
UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
} else {
this._activeRecentlyTimeout.restart();
}
this.activeRecentlyTimeout.restart();
}
};
async _runTimersUntilTimeout(attachedTimers, timeout) {
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();

View file

@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {Room} from "matrix-js-sdk/src/models/room";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {MatrixClientPeg} from "./MatrixClientPeg";
import { _t } from './languageHandler';
export function usersTypingApartFromMeAndIgnored(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
);
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers()));
}
export function usersTypingApartFromMe(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId],
);
export function usersTypingApartFromMe(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()]);
}
/**
@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) {
* to exclude, return a list of user objects who are typing.
* @param {Room} room: room object to get users from.
* @param {string[]} exclude: list of user mxids to exclude.
* @returns {string[]} list of user objects who are typing.
* @returns {RoomMember[]} list of user objects who are typing.
*/
export function usersTyping(room, exclude) {
export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
const whoIsTyping = [];
if (exclude === undefined) {
exclude = [];
}
const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i];
@ -57,19 +52,20 @@ export function usersTyping(room, exclude) {
return whoIsTyping;
}
export function whoIsTypingString(whoIsTyping, limit) {
export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
if (whoIsTyping.length === 0) {
return '';
} else if (whoIsTyping.length === 1) {
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
}
const names = whoIsTyping.map(function(m) {
return m.name;
});
const names = whoIsTyping.map(m => m.name);
if (othersCount >= 1) {
return _t('%(names)s and %(count)s others are typing …', {
names: names.slice(0, limit - 1).join(', '),

View file

@ -1,242 +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);
});
}
sendWidgetConfig(widgetConfig: any) {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.GetWidgetConfig,
data: widgetConfig,
}).catch((error) => {
console.error("Failed to send widget info: ", error);
});
}
sendModalButtonClicked(id: string) {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.ButtonClicked,
data: {id},
}).catch((error) => {
console.error("Failed to send modal widget button clicked: ", error);
});
}
sendModalCloseInfo(info: any) {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.CloseModalWidget,
data: info,
}).catch((error) => {
console.error("Failed to send modal widget close info: ", 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) => {
let handled = false;
if (handleHomeEnd) {
// Don't interfere with input default keydown behaviour
if (handleHomeEnd && ev.target.tagName !== "INPUT") {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:

View file

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

View file

@ -17,14 +17,14 @@ limitations under the License.
import Analytics from '../Analytics';
import { asyncAction } from './actionCreators';
import TagOrderStore from '../stores/TagOrderStore';
import GroupFilterOrderStore from '../stores/GroupFilterOrderStore';
import { AsyncActionPayload } from "../dispatcher/payloads";
import { MatrixClient } from "matrix-js-sdk/src/client";
export default class TagOrderActions {
/**
* Creates an action thunk that will do an asynchronous request to
* move a tag in TagOrderStore to destinationIx.
* move a tag in GroupFilterOrderStore to destinationIx.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
@ -36,8 +36,8 @@ export default class TagOrderActions {
*/
public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload {
// Only commit tags if the state is ready, i.e. not null
let tags = TagOrderStore.getOrderedTags();
let removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
let tags = GroupFilterOrderStore.getOrderedTags();
let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
if (!tags) {
return;
}
@ -47,7 +47,7 @@ export default class TagOrderActions {
removedTags = removedTags.filter((t) => t !== tag);
const storeId = TagOrderStore.getStoreId();
const storeId = GroupFilterOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
@ -83,8 +83,8 @@ export default class TagOrderActions {
*/
public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload {
// Don't change tags, just removedTags
const tags = TagOrderStore.getOrderedTags();
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
const tags = GroupFilterOrderStore.getOrderedTags();
const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
if (removedTags.includes(tag)) {
// Return a thunk that doesn't do anything, we don't even need
@ -94,7 +94,7 @@ export default class TagOrderActions {
removedTags.push(tag);
const storeId = TagOrderStore.getStoreId();
const storeId = GroupFilterOrderStore.getStoreId();
return asyncAction('TagOrderActions.removeTag', () => {
Analytics.trackEvent('TagOrderActions', 'removeTag');

View file

@ -32,6 +32,7 @@ import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils';
import SecurityCustomisations from "../../../../customisations/Security";
const PHASE_LOADING = 0;
const PHASE_LOADERROR = 1;
@ -99,7 +100,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this._passphraseField = createRef();
this._fetchBackupInfo();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this.state.accountPassword) {
// If we have an account password in memory, let's simplify and
// assume it means password auth is also supported for device
@ -110,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this._queryKeyUploadAuth();
}
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
this._getInitialPhase();
}
componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
}
_getInitialPhase() {
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Created key via customisations, jumping to bootstrap step");
this._recoveryKey = {
privateKey: keyFromCustomisations,
};
this._bootstrapSecretStorage();
return;
}
this._fetchBackupInfo();
}
async _fetchBackupInfo() {
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import TagOrderStore from '../../stores/TagOrderStore';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
import GroupActions from '../../actions/GroupActions';
@ -31,7 +31,7 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
class TagPanel extends React.Component {
class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext;
state = {
@ -44,13 +44,13 @@ class TagPanel extends React.Component {
this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
if (this.unmounted) {
return;
}
this.setState({
orderedTags: TagOrderStore.getOrderedTags() || [],
selectedTags: TagOrderStore.getSelectedTags(),
orderedTags: GroupFilterOrderStore.getOrderedTags() || [],
selectedTags: GroupFilterOrderStore.getSelectedTags(),
});
});
// This could be done by anything with a matrix client
@ -61,8 +61,8 @@ class TagPanel extends React.Component {
this.unmounted = true;
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.removeListener("sync", this._onClientSync);
if (this._tagOrderStoreToken) {
this._tagOrderStoreToken.remove();
if (this._groupFilterOrderStoreToken) {
this._groupFilterOrderStoreToken.remove();
}
}
@ -98,7 +98,7 @@ class TagPanel extends React.Component {
return (
<div>
<UserTagTile />
<hr className="mx_TagPanel_divider" />
<hr className="mx_GroupFilterPanel_divider" />
</div>
);
}
@ -117,8 +117,8 @@ class TagPanel extends React.Component {
});
const itemsSelected = this.state.selectedTags.length > 0;
const classes = classNames('mx_TagPanel', {
mx_TagPanel_items_selected: itemsSelected,
const classes = classNames('mx_GroupFilterPanel', {
mx_GroupFilterPanel_items_selected: itemsSelected,
});
let createButton = (
@ -141,7 +141,7 @@ class TagPanel extends React.Component {
return <div className={classes} onClick={this.onClearFilterClick}>
<AutoHideScrollbar
className="mx_TagPanel_scroller"
className="mx_GroupFilterPanel_scroller"
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown}
@ -152,7 +152,7 @@ class TagPanel extends React.Component {
>
{ (provided, snapshot) => (
<div
className="mx_TagPanel_tagTileContainer"
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
@ -168,4 +168,4 @@ class TagPanel extends React.Component {
</div>;
}
}
export default TagPanel;
export default GroupFilterPanel;

View file

@ -620,7 +620,7 @@ export default class GroupView extends React.Component {
profileForm: newProfileForm,
// Indicate that FlairStore needs to be poked to show this change
// in TagTile (TagPanel), Flair and GroupTile (MyGroups).
// in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups).
avatarChanged: true,
});
}).catch((e) => {
@ -649,7 +649,6 @@ export default class GroupView extends React.Component {
editing: false,
summary: null,
});
dis.dispatch({action: 'panel_disable'});
this._initGroupStore(this.props.groupId);
if (this.state.avatarChanged) {
@ -870,10 +869,7 @@ export default class GroupView extends React.Component {
{ _t('Add rooms to this community') }
</div>
</AccessibleButton>) : <div />;
const roomDetailListClassName = classnames({
"mx_fadable": true,
"mx_fadable_faded": this.state.editing,
});
return <div className="mx_GroupView_rooms">
<div className="mx_GroupView_rooms_header">
<h3>
@ -884,9 +880,7 @@ export default class GroupView extends React.Component {
</div>
{ this.state.groupRoomsLoading ?
<Spinner /> :
<RoomDetailList
rooms={this.state.groupRooms}
className={roomDetailListClassName} />
<RoomDetailList rooms={this.state.groupRooms} />
}
</div>;
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import TagPanel from "./TagPanel";
import GroupFilterPanel from "./GroupFilterPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
@ -46,7 +46,7 @@ interface IProps {
interface IState {
showBreadcrumbs: boolean;
showTagPanel: boolean;
showGroupFilterPanel: boolean;
}
// List of CSS classes which should be included in keyboard navigation within the room list
@ -60,7 +60,7 @@ const cssClasses = [
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string;
private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string;
private focusedElement = null;
private isDoingStickyHeaders = false;
@ -70,7 +70,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
@ -78,8 +78,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
});
// We watch the middle panel because we don't actually get resized, the middle panel does.
@ -88,7 +88,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
@ -119,8 +119,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (settingBgMxc) {
avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize);
}
const avatarUrlProp = `url(${avatarUrl})`;
if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
if (!avatarUrl) {
document.body.style.removeProperty("--avatar-url");
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
document.body.style.setProperty("--avatar-url", avatarUrlProp);
}
};
@ -375,9 +378,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const tagPanel = !this.state.showTagPanel ? null : (
<div className="mx_LeftPanel_tagPanelContainer">
<TagPanel />
const groupFilterPanel = !this.state.showGroupFilterPanel ? null : (
<div className="mx_LeftPanel_GroupFilterPanelContainer">
<GroupFilterPanel />
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
</div>
);
@ -394,7 +397,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const containerClasses = classNames({
"mx_LeftPanel": true,
"mx_LeftPanel_hasTagPanel": !!tagPanel,
"mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel,
"mx_LeftPanel_minimized": this.props.isMinimized,
});
@ -405,7 +408,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return (
<div className={containerClasses}>
{tagPanel}
{groupFilterPanel}
<aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}

View file

@ -27,7 +27,6 @@ import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import sessionStore from '../../stores/SessionStore';
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
@ -41,10 +40,6 @@ import HomePage from "./HomePage";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg";
import { DefaultTagID } from "../../stores/room-list/models";
import {
showToast as showSetPasswordToast,
hideToast as hideSetPasswordToast,
} from "../../toasts/SetPasswordToast";
import {
showToast as showServerLimitToast,
hideToast as hideServerLimitToast,
@ -76,9 +71,6 @@ interface IProps {
viaServers?: string[];
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
middleDisabled: boolean;
leftDisabled: boolean;
rightDisabled: boolean;
// eslint-disable-next-line camelcase
page_type: string;
autoJoin: boolean;
@ -105,10 +97,6 @@ interface IUsageLimit {
}
interface IState {
mouseDown?: {
x: number;
y: number;
};
syncErrorData?: {
error: {
data: IUsageLimit;
@ -149,8 +137,6 @@ class LoggedInView extends React.Component<IProps, IState> {
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _sessionStore: sessionStore;
protected readonly _sessionStoreToken: { remove: () => void };
protected readonly _compactLayoutWatcherRef: string;
protected resizer: Resizer;
@ -158,7 +144,6 @@ class LoggedInView extends React.Component<IProps, IState> {
super(props, context);
this.state = {
mouseDown: undefined,
syncErrorData: undefined,
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
@ -171,12 +156,6 @@ class LoggedInView extends React.Component<IProps, IState> {
document.addEventListener('keydown', this._onNativeKeyDown, false);
this._sessionStore = sessionStore;
this._sessionStoreToken = this._sessionStore.addListener(
this._setStateFromSessionStore,
);
this._setStateFromSessionStore();
this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
@ -205,9 +184,6 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
this.resizer.detach();
}
@ -228,20 +204,13 @@ class LoggedInView extends React.Component<IProps, IState> {
return this._roomView.current.canResetTimeline();
};
_setStateFromSessionStore = () => {
if (this._sessionStore.getCachedPassword()) {
showSetPasswordToast();
} else {
hideSetPasswordToast();
}
};
_createResizer() {
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
};
let size;
const collapseConfig = {
toggleSize: 260 - 50,
onCollapsed: (collapsed) => {
@ -252,21 +221,19 @@ class LoggedInView extends React.Component<IProps, IState> {
dis.dispatch({action: "show_left_panel"}, true);
}
},
onResized: (size) => {
window.localStorage.setItem("mx_lhs_size", '' + size);
onResized: (_size) => {
size = _size;
this.props.resizeNotifier.notifyLeftHandleResized();
},
onResizeStart: () => {
this.props.resizeNotifier.startResizing();
},
onResizeStop: () => {
window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.stopResizing();
},
};
const resizer = new Resizer(
this._resizeContainer.current,
CollapseDistributor,
collapseConfig);
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames(classNames);
return resizer;
}
@ -542,8 +509,8 @@ class LoggedInView extends React.Component<IProps, IState> {
// Could be "GroupTile +groupId:domain"
const draggableId = result.draggableId.split(' ').pop();
// Dispatch synchronously so that the TagPanel receives an
// optimistic update from TagOrderStore before the previous
// Dispatch synchronously so that the GroupFilterPanel receives an
// optimistic update from GroupFilterOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this._matrixClient,
@ -574,48 +541,6 @@ class LoggedInView extends React.Component<IProps, IState> {
), true);
};
_onMouseDown = (ev) => {
// When the panels are disabled, clicking on them results in a mouse event
// which bubbles to certain elements in the tree. When this happens, close
// any settings page that is currently open (user/room/group).
if (this.props.leftDisabled && this.props.rightDisabled) {
const targetClasses = new Set(ev.target.className.split(' '));
if (
targetClasses.has('mx_MatrixChat') ||
targetClasses.has('mx_MatrixChat_middlePanel') ||
targetClasses.has('mx_RoomView')
) {
this.setState({
mouseDown: {
x: ev.pageX,
y: ev.pageY,
},
});
}
}
};
_onMouseUp = (ev) => {
if (!this.state.mouseDown) return;
const deltaX = ev.pageX - this.state.mouseDown.x;
const deltaY = ev.pageY - this.state.mouseDown.y;
const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY));
const maxRadius = 5; // People shouldn't be straying too far, hopefully
// Note: we track how far the user moved their mouse to help
// combat against https://github.com/vector-im/element-web/issues/7158
if (distance < maxRadius) {
// This is probably a real click, and not a drag
dis.dispatch({ action: 'close_settings' });
}
// Always clear the mouseDown state to ensure we don't accidentally
// use stale values due to the mouseDown checks.
this.setState({mouseDown: null});
};
render() {
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
@ -635,7 +560,6 @@ class LoggedInView extends React.Component<IProps, IState> {
oobData={this.props.roomOobData}
viaServers={this.props.viaServers}
key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
resizeNotifier={this.props.resizeNotifier}
/>;
break;
@ -683,8 +607,6 @@ class LoggedInView extends React.Component<IProps, IState> {
onKeyDown={this._onReactKeyDown}
className='mx_MatrixChat_wrapper'
aria-hidden={this.props.hideToSRUsers}
onMouseDown={this._onMouseDown}
onMouseUp={this._onMouseUp}
>
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>

View file

@ -30,7 +30,7 @@ import 'what-input';
import Analytics from "../../Analytics";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter";
@ -181,9 +181,6 @@ interface IState {
currentUserId?: string;
// this is persisted as mx_lhs_size, loaded in LoggedInView
collapseLhs: boolean;
leftDisabled: boolean;
middleDisabled: boolean;
// the right panel's disabled state is tracked in its store.
// Parameters used in the registration dance with the IS
// eslint-disable-next-line camelcase
register_client_secret?: string;
@ -236,8 +233,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state = {
view: Views.LOADING,
collapseLhs: false,
leftDisabled: false,
middleDisabled: false,
hideToSRUsers: false,
@ -290,7 +285,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
// will be sent out to say that, triggering this MatrixChat to show the soft
// logout page.
Lifecycle.loadSession({});
Lifecycle.loadSession();
}
this.accountPassword = null;
@ -670,9 +665,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'view_home_page':
this.viewHome();
break;
case 'view_set_mxid':
this.setMxId(payload);
break;
case 'view_start_chat_or_reuse':
this.chatCreateOrReuse(payload.user_id);
break;
@ -713,14 +705,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
case 'panel_disable': {
this.setState({
leftDisabled: payload.leftDisabled || payload.sideDisabled || false,
middleDisabled: payload.middleDisabled || false,
// We don't track the right panel being disabled here - it's tracked in the store.
});
break;
}
case 'on_logged_in':
if (
!Lifecycle.isSoftLogout() &&
@ -985,41 +969,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
private setMxId(payload) {
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(),
onFinished: (submitted, credentials) => {
if (!submitted) {
dis.dispatch({
action: 'cancel_after_sync_prepared',
});
if (payload.go_home_on_cancel) {
dis.dispatch({
action: 'view_home_page',
});
}
return;
}
MatrixClientPeg.setJustRegisteredUserId(credentials.user_id);
this.onRegistered(credentials);
},
onDifferentServerClicked: (ev) => {
dis.dispatch({action: 'start_registration'});
close();
},
onLoginClick: (ev) => {
dis.dispatch({action: 'start_login'});
close();
},
}).close;
}
private async createRoom(defaultPublic = false) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
if (communityId) {
// double check the user will have permission to associate this room with the community
if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
if (!CommunityPrototypeStore.instance.isAdminOf(communityId)) {
Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, {
title: _t("Cannot create rooms in this community"),
description: _t("You do not have permission to create rooms in this community."),
@ -1814,12 +1768,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.showScreen("forgot_password");
};
onRegisterFlowComplete = (credentials: object, password: string) => {
onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string) => {
return this.onUserCompletedLoginFlow(credentials, password);
};
// returns a promise which resolves to the new MatrixClient
onRegistered(credentials: object) {
onRegistered(credentials: IMatrixClientCreds) {
return Lifecycle.setLoggedIn(credentials);
}
@ -1905,7 +1859,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* Note: SSO users (and any others using token login) currently do not pass through
* this, as they instead jump straight into the app after `attemptTokenLogin`.
*/
onUserCompletedLoginFlow = async (credentials: object, password: string) => {
onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string) => {
this.accountPassword = password;
// self-destruct the password after 5mins
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);

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 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,7 +17,6 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {Room} from "matrix-js-sdk/src/models/room";
import * as sdk from '../../index';
@ -162,7 +158,7 @@ export default class RightPanel extends React.Component {
}
onRoomStateMember(ev, state, member) {
if (member.roomId !== this.props.room.roomId) {
if (!this.props.room || member.roomId !== this.props.room.roomId) {
return;
}
// redraw the badge on the membership list
@ -202,13 +198,19 @@ export default class RightPanel extends React.Component {
dis.dispatch({
action: "view_home_page",
});
} else if (this.state.phase === RightPanelPhases.EncryptionPanel &&
this.state.verificationRequest && this.state.verificationRequest.pending
) {
// When the user clicks close on the encryption panel cancel the pending request first if any
this.state.verificationRequest.cancel();
} else {
// Otherwise we have got our user from RoomViewStore which means we're being shown
// within a room/group, so go back to the member panel if we were in the encryption panel,
// or the member list if we were in the member panel... phew.
const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
dis.dispatch({
action: Action.ViewUser,
member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null,
member: isEncryptionPhase ? this.state.member : null,
});
}
};
@ -301,14 +303,8 @@ export default class RightPanel extends React.Component {
break;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {
"collapsed": this.props.collapsed,
"mx_fadable_faded": this.props.disabled,
"dark-panel": true,
});
return (
<aside className={classes} id="mx_RightPanel">
<aside className="mx_RightPanel dark-panel" id="mx_RightPanel">
{ panel }
</aside>
);

View file

@ -30,12 +30,12 @@ import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore";
import TagOrderStore from "../../stores/TagOrderStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160;
const MAX_TOPIC_LENGTH = 800;
function track(action) {
Analytics.trackEvent('RoomDirectory', action);
@ -49,7 +49,7 @@ export default class RoomDirectory extends React.Component {
constructor(props) {
super(props);
const selectedCommunityId = TagOrderStore.getSelectedTags()[0];
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = {
publicRooms: [],
loading: true,
@ -497,6 +497,9 @@ export default class RoomDirectory extends React.Component {
}
let topic = room.topic || '';
// Additional truncation based on line numbers is done via CSS,
// but to ensure that the DOM is not polluted with a huge string
// we give it a hard limit before rendering.
if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015-2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,6 +24,7 @@ import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -46,10 +45,12 @@ export default class RoomStatusBar extends React.Component {
// Used to suggest to the user to invite someone
sentMessageAndIsAlone: PropTypes.bool,
// true if there is an active call in this room (means we show
// the 'Active Call' text in the status bar if there is nothing
// more interesting)
hasActiveCall: PropTypes.bool,
// The active call in the room, if any (means we show the call bar
// along with the status of the call)
callState: PropTypes.string,
// The type of the call in progress, or null if no call is in progress
callType: PropTypes.string,
// true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room.
@ -121,6 +122,12 @@ export default class RoomStatusBar extends React.Component {
});
};
_showCallBar() {
return (this.props.callState &&
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
);
}
_onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer);
@ -153,7 +160,7 @@ export default class RoomStatusBar extends React.Component {
// indicate other sizes.
_getSize() {
if (this._shouldShowConnectionError() ||
this.props.hasActiveCall ||
this._showCallBar() ||
this.props.sentMessageAndIsAlone
) {
return STATUS_BAR_EXPANDED;
@ -165,7 +172,7 @@ export default class RoomStatusBar extends React.Component {
// return suitable content for the image on the left of the status bar.
_getIndicator() {
if (this.props.hasActiveCall) {
if (this._showCallBar()) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
@ -269,6 +276,25 @@ export default class RoomStatusBar extends React.Component {
</div>;
}
_getCallStatusText() {
switch (this.props.callState) {
case CallState.CreateOffer:
case CallState.InviteSent:
return _t('Calling...');
case CallState.Connecting:
case CallState.CreateAnswer:
return _t('Call connecting...');
case CallState.Connected:
return _t('Active call');
case CallState.WaitLocalMedia:
if (this.props.callType === CallType.Video) {
return _t('Starting camera...');
} else {
return _t('Starting microphone...');
}
}
}
// return suitable content for the main (text) part of the status bar.
_getContent() {
if (this._shouldShowConnectionError()) {
@ -291,10 +317,10 @@ export default class RoomStatusBar extends React.Component {
return this._getUnsentMessageContent();
}
if (this.props.hasActiveCall) {
if (this._showCallBar()) {
return (
<div className="mx_RoomStatusBar_callBar">
<b>{ _t('Active call') }</b>
<b>{ this._getCallStatusText() }</b>
</div>
);
}

View file

@ -71,6 +71,7 @@ import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg";
import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -104,7 +105,6 @@ interface IProps {
viaServers?: string[];
autoJoin?: boolean;
disabled?: boolean;
resizeNotifier: ResizeNotifier;
// Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU)
@ -141,7 +141,7 @@ export interface IState {
}>;
searchHighlights?: string[];
searchInProgress?: boolean;
callState?: string;
callState?: CallState;
guestsCanJoin: boolean;
canPeek: boolean;
showApps: boolean;
@ -479,7 +479,7 @@ export default class RoomView extends React.Component<IProps, IState> {
componentDidMount() {
const call = this.getCallForRoom();
const callState = call ? call.call_state : "ended";
const callState = call ? call.state : null;
this.setState({
callState: callState,
});
@ -712,14 +712,9 @@ export default class RoomView extends React.Component<IProps, IState> {
}
const call = this.getCallForRoom();
let callState = "ended";
if (call) {
callState = call.call_state;
}
this.setState({
callState: callState,
callState: call ? call.state : null,
});
break;
}
@ -1090,42 +1085,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room_id: this.getRoomId(),
},
});
// Don't peek whilst registering otherwise getPendingEventList complains
// Do this by indicating our intention to join
// XXX: ILAG is disabled for now,
// see https://github.com/vector-im/element-web/issues/8222
dis.dispatch({action: 'require_registration'});
// dis.dispatch({
// action: 'will_join',
// });
// const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
// const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
// homeserverUrl: cli.getHomeserverUrl(),
// onFinished: (submitted, credentials) => {
// if (submitted) {
// this.props.onRegistered(credentials);
// } else {
// dis.dispatch({
// action: 'cancel_after_sync_prepared',
// });
// dis.dispatch({
// action: 'cancel_join',
// });
// }
// },
// onDifferentServerClicked: (ev) => {
// dis.dispatch({action: 'start_registration'});
// close();
// },
// onLoginClick: (ev) => {
// dis.dispatch({action: 'start_login'});
// close();
// },
// }).close;
// return;
} else {
Promise.resolve().then(() => {
const signUrl = this.props.threepidInvite?.signUrl;
@ -1640,7 +1600,7 @@ export default class RoomView extends React.Component<IProps, IState> {
/**
* get any current call for this room
*/
private getCallForRoom() {
private getCallForRoom(): MatrixCall {
if (!this.state.room) {
return null;
}
@ -1777,10 +1737,13 @@ export default class RoomView extends React.Component<IProps, IState> {
// We have successfully loaded this room, and are not previewing.
// Display the "normal" room view.
let activeCall = null;
{
// New block because this variable doesn't need to hang around for the rest of the function
const call = this.getCallForRoom();
let inCall = false;
if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) {
inCall = true;
activeCall = call;
}
}
const scrollheaderClasses = classNames({
@ -1799,7 +1762,8 @@ export default class RoomView extends React.Component<IProps, IState> {
statusBar = <RoomStatusBar
room={this.state.room}
sentMessageAndIsAlone={this.state.isAlone}
hasActiveCall={inCall}
callState={this.state.callState}
callType={activeCall ? activeCall.type : null}
isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick}
@ -1820,7 +1784,6 @@ export default class RoomView extends React.Component<IProps, IState> {
let aux = null;
let previewBar;
let hideCancel = false;
let forceHideRightPanel = false;
if (this.state.forwardingEvent) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
@ -1865,8 +1828,6 @@ export default class RoomView extends React.Component<IProps, IState> {
{ previewBar }
</div>
);
} else {
forceHideRightPanel = true;
}
} else if (hiddenHighlightCount > 0) {
aux = (
@ -1891,7 +1852,6 @@ export default class RoomView extends React.Component<IProps, IState> {
draggingFile={this.state.draggingFile}
maxHeight={this.state.auxPanelMaxHeight}
showApps={this.state.showApps}
hideAppsDrawer={false}
onResize={this.onResize}
resizeNotifier={this.props.resizeNotifier}
>
@ -1910,7 +1870,6 @@ export default class RoomView extends React.Component<IProps, IState> {
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
resizeNotifier={this.props.resizeNotifier}
@ -1928,10 +1887,10 @@ export default class RoomView extends React.Component<IProps, IState> {
};
}
if (inCall) {
if (activeCall) {
let zoomButton; let videoMuteButton;
if (call.type === "video") {
if (activeCall.type === CallType.Video) {
zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg
@ -1946,10 +1905,11 @@ export default class RoomView extends React.Component<IProps, IState> {
videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg
src={call.isLocalVideoMuted() ?
src={activeCall.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")}
alt={call.isLocalVideoMuted() ? _t("Click to unmute video") : _t("Click to mute video")}
alt={activeCall.isLocalVideoMuted() ? _t("Click to unmute video") :
_t("Click to mute video")}
width=""
height="27"
/>
@ -1958,10 +1918,10 @@ export default class RoomView extends React.Component<IProps, IState> {
const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg
src={call.isMicrophoneMuted() ?
src={activeCall.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={call.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
alt={activeCall.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21"
height="26"
/>
@ -2065,11 +2025,7 @@ export default class RoomView extends React.Component<IProps, IState> {
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
});
const fadableSectionClasses = classNames("mx_RoomView_body", "mx_fadable", {
"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
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
: null;
@ -2079,7 +2035,7 @@ export default class RoomView extends React.Component<IProps, IState> {
});
const mainClasses = classNames("mx_RoomView", {
mx_RoomView_inCall: inCall,
mx_RoomView_inCall: Boolean(activeCall),
});
return (
@ -2100,7 +2056,7 @@ export default class RoomView extends React.Component<IProps, IState> {
e2eStatus={this.state.e2eStatus}
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className={fadableSectionClasses}>
<div className="mx_RoomView_body">
{auxPanel}
<div className={timelineClasses}>
{topUnreadMessagesBar}

View file

@ -44,7 +44,7 @@ import IconizedContextMenu, {
} from "../views/context_menus/IconizedContextMenu";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import * as fbEmitter from "fbemitter";
import TagOrderStore from "../../stores/TagOrderStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import { showCommunityInviteDialog } from "../../RoomInvite";
import dis from "../../dispatcher/dispatcher";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
@ -87,7 +87,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
}
public componentWillUnmount() {

View file

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

View file

@ -22,6 +22,7 @@ import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
interface IProps {
// Room may be left unset here, but if it is,
@ -32,7 +33,7 @@ interface IProps {
oobData?: any;
width?: number;
height?: number;
resizeMethod?: string;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
}

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>
<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_textInput">
<Field
@ -298,6 +303,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
value={this.state.recoveryKey}
onChange={this._onRecoveryKeyChange}
forceValidity={this.state.recoveryKeyCorrect}
autoComplete="off"
/>
</div>
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">

View file

@ -62,7 +62,8 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
};
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
className="mx_AccessibleTooltipButton_container"

View file

@ -18,11 +18,9 @@ limitations under the License.
*/
import url from 'url';
import qs from 'qs';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import WidgetMessaging from '../../../WidgetMessaging';
import AccessibleButton from './AccessibleButton';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType";
import {Capability} from "../../../widgets/WidgetApi";
import {sleep} from "../../../utils/promise";
import {SettingLevel} from "../../../settings/SettingLevel";
import WidgetStore from "../../../stores/WidgetStore";
import {Action} from "../../../dispatcher/actions";
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const ENABLE_REACT_PERF = false;
/**
* Does template substitution on a URL (or any string). Variables will be
* passed through encodeURIComponent.
* @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'.
* @param {Object} variables The key/value pairs to replace the template
* variables with. E.g. { '$bar': 'baz' }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
function uriFromTemplate(uriTemplate, variables) {
let out = uriTemplate;
for (const [key, val] of Object.entries(variables)) {
out = out.replace(
'$' + key, encodeURIComponent(val),
);
}
return out;
}
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
import {MatrixCapabilities} from "matrix-widget-api";
export default class AppTile extends React.Component {
constructor(props) {
@ -72,11 +49,14 @@ export default class AppTile extends React.Component {
// The key used for PersistedElement
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._onAction = this._onAction.bind(this);
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.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._contextMenuButton = createRef();
this._appFrame = createRef();
this._menu_bar = createRef();
}
@ -108,12 +87,10 @@ export default class AppTile extends React.Component {
return !!currentlyAllowedWidgets[newProps.app.eventId];
};
const PersistedElement = sdk.getComponent("elements.PersistedElement");
return {
initialising: true, // True while we are mangling the widget URL
// True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
widgetUrl: this._addWurlParams(newProps.app.url),
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
@ -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() {
const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url);
@ -176,7 +116,7 @@ export default class AppTile extends React.Component {
componentDidMount() {
// Only fetch IM token on mount if we're showing and have permission to load
if (this.props.show && this.state.hasPermissionToLoad) {
this.setScalarToken();
this._startWidget();
}
// 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 (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
}
if (this._sgWidget) {
this._sgWidget.stop();
}
}
// TODO: Generify the name of this function. It's not just scalar tokens.
/**
* Adds a scalar token to the widget URL, if required
* Component initialisation is only complete when this function has resolved
*/
setScalarToken() {
if (!WidgetUtils.isScalarUrl(this.props.app.url)) {
console.warn('Widget does not match integration manager, refusing to set auth token', url);
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.app.url),
initialising: false,
});
return;
_resetWidget(newProps) {
if (this._sgWidget) {
this._sgWidget.stop();
}
this._sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this._startWidget();
}
const managers = IntegrationManagers.sharedInstance();
if (!managers.hasManager()) {
console.warn("No integration manager - not setting scalar token", url);
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,
});
_startWidget() {
this._sgWidget.prepare().then(() => {
this.setState({initialising: false});
});
}
_iframeRefChange = (ref) => {
this.iframe = ref;
if (ref) {
this._sgWidget.start(ref);
} else {
this._resetWidget(this.props);
}
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
if (nextProps.app.url !== this.props.app.url) {
this._getNewState(nextProps);
// Fetch IM token for new URL if we're showing and have permission to load
if (this.props.show && this.state.hasPermissionToLoad) {
this.setScalarToken();
this._resetWidget(nextProps);
}
}
@ -287,9 +179,9 @@ export default class AppTile extends React.Component {
loading: true,
});
}
// Fetch IM token now that we're showing if we already have permission to load
// Start the widget now that we're showing if we already have permission to load
if (this.state.hasPermissionToLoad) {
this.setScalarToken();
this._startWidget();
}
}
@ -319,7 +211,14 @@ export default class AppTile extends React.Component {
}
_onSnapshotClick() {
WidgetUtils.snapshotWidget(this.props.app);
this._sgWidget.widgetApi.takeScreenshot().then(data => {
dis.dispatch({
action: 'picture_snapshot',
file: data.screenshot,
});
}).catch(err => {
console.error("Failed to take screenshot: ", err);
});
}
/**
@ -327,35 +226,28 @@ export default class AppTile extends React.Component {
* @private
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
*/
_endWidgetActions() {
let terminationPromise;
if (this._hasCapability(Capability.ReceiveTerminate)) {
// Wait for widget to terminate within a timeout
const timeout = 2000;
const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
} else {
terminationPromise = Promise.resolve();
}
return terminationPromise.finally(() => {
async _endWidgetActions() { // widget migration dev note: async to maintain signature
// HACK: This is a really dirty way to ensure that Jitsi cleans up
// its hold on the webcam. Without this, the widget holds a media
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
if (this._appFrame.current) {
if (this.iframe) {
// In practice we could just do `+= ''` to trick the browser
// into thinking the URL changed, however I can foresee this
// being optimized out by a browser. Instead, we'll just point
// the iframe at a page that is reasonably safe to use in the
// event the iframe doesn't wink away.
// This is relative to where the Element instance is located.
this._appFrame.current.src = 'about:blank';
this.iframe.src = 'about:blank';
}
if (WidgetType.JITSI.matches(this.props.app.type)) {
dis.dispatch({action: 'hangup_conference'});
}
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
});
this._sgWidget.stop({forceDestroy: true});
}
/* If user has permission to modify widgets, delete the widget,
@ -409,73 +301,21 @@ export default class AppTile extends React.Component {
this._revokeWidgetPermission();
}
/**
* Called when widget iframe has finished loading
*/
_onLoaded() {
// Destroy the old widget messaging before starting it back up again. Some widgets
// have startup routines that run when they are loaded, so we just need to reinitialize
// the messaging for them.
ActiveWidgetStore.delWidgetMessaging(this.props.app.id);
this._setupWidgetMessaging();
ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId);
_onWidgetPrepared = () => {
this.setState({loading: false});
}
};
_setupWidgetMessaging() {
// FIXME: There's probably no reason to do this here: it should probably be done entirely
// in ActiveWidgetStore.
const widgetMessaging = new WidgetMessaging(
this.props.app.id,
this.props.app.url,
this._getRenderedUrl(),
this.props.userWidget,
this._appFrame.current.contentWindow,
);
ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging);
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities);
requestedCapabilities = requestedCapabilities || [];
// Allow whitelisted capabilities
let requestedWhitelistCapabilies = [];
if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) {
requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) {
return this.indexOf(e)>=0;
}, this.props.whitelistCapabilities);
if (requestedWhitelistCapabilies.length > 0 ) {
console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` +
requestedWhitelistCapabilies,
);
}
}
// TODO -- Add UI to warn about and optionally allow requested capabilities
ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies);
if (this.props.onCapabilityRequest) {
this.props.onCapabilityRequest(requestedCapabilities);
}
// We only tell Jitsi widgets that we're ready because they're realistically the only ones
// using this custom extension to the widget API.
_onWidgetReady = () => {
if (WidgetType.JITSI.matches(this.props.app.type)) {
widgetMessaging.flagReadyToContinue();
}
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err);
});
this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
}
};
_onAction(payload) {
if (payload.widgetId === this.props.app.id) {
switch (payload.action) {
case 'm.sticker':
if (this._hasCapability('m.sticker')) {
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({action: 'post_sticker_message', data: payload.data});
} else {
console.warn('Ignoring sticker message. Invalid capability');
@ -493,20 +333,6 @@ export default class AppTile extends React.Component {
}
}
/**
* Set remote content title on AppTile
* @param {string} url Url to check for title
*/
_fetchWidgetTitle(url) {
this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => {
if (widgetPageTitle) {
this.setState({widgetPageTitle: widgetPageTitle});
}
}, (err) =>{
console.error("Failed to get page title", err);
});
}
_grantWidgetPermission() {
const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId);
@ -516,7 +342,7 @@ export default class AppTile extends React.Component {
this.setState({hasPermissionToLoad: true});
// Fetch a token for the integration manager, now that we're allowed to
this.setScalarToken();
this._startWidget();
}).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
@ -535,6 +361,7 @@ export default class AppTile extends React.Component {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop();
}).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
@ -564,6 +391,9 @@ export default class AppTile extends React.Component {
if (this.props.show) {
// if we were being shown, end the widget as we're about to be minimized.
this._endWidgetActions();
} else {
// restart the widget actions
this._resetWidget(this.props);
}
dis.dispatch({
action: 'appsDrawer',
@ -572,40 +402,6 @@ export default class AppTile extends React.Component {
}
}
/**
* Replace the widget template variables in a url with their values
*
* @param {string} u The URL with template variables
* @param {string} widgetType The widget's type
*
* @returns {string} url with temlate variables replaced
*/
_templatedUrl(u, widgetType: string) {
const targetData = {};
if (WidgetType.JITSI.matches(widgetType)) {
targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded
}
const myUserId = MatrixClientPeg.get().credentials.userId;
const myUser = MatrixClientPeg.get().getUser(myUserId);
const vars = Object.assign(targetData, this.props.app.data, {
'matrix_user_id': myUserId,
'matrix_room_id': this.props.room.roomId,
'matrix_display_name': myUser ? myUser.displayName : myUserId,
'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '',
// TODO: Namespace themes through some standard
'theme': SettingsStore.getValue("theme"),
});
if (vars.conferenceId === undefined) {
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
const parsedUrl = new URL(this.props.app.url);
vars.conferenceId = parsedUrl.searchParams.get("confId");
}
return uriFromTemplate(u, vars);
}
/**
* Whether we're using a local version of the widget rather than loading the
* actual widget URL
@ -615,67 +411,11 @@ export default class AppTile extends React.Component {
return WidgetType.JITSI.matches(this.props.app.type);
}
/**
* Get the URL used in the iframe
* In cases where we supply our own UI for a widget, this is an internal
* URL different to the one used if the widget is popped out to a separate
* tab / browser
*
* @returns {string} url
*/
_getRenderedUrl() {
let url;
if (WidgetType.JITSI.matches(this.props.app.type)) {
console.log("Replacing Jitsi widget URL with local wrapper");
url = WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: true,
auth: this.props.app.data ? this.props.app.data.auth : null,
});
url = this._addWurlParams(url);
} else {
url = this._getSafeUrl(this.state.widgetUrl);
}
return this._templatedUrl(url, this.props.app.type);
}
_getPopoutUrl() {
if (WidgetType.JITSI.matches(this.props.app.type)) {
return this._templatedUrl(
WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: false,
auth: this.props.app.data ? this.props.app.data.auth : null,
}),
this.props.app.type,
);
} else {
// use app.url, not state.widgetUrl, because we want the one without
// the wURL params for the popped-out version.
return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type);
}
}
_getSafeUrl(u) {
const parsedWidgetUrl = url.parse(u, true);
if (ENABLE_REACT_PERF) {
parsedWidgetUrl.search = null;
parsedWidgetUrl.query.react_perf = true;
}
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
// Replace all the dollar signs back to dollar signs as they don't affect HTTP at all.
// We also need the dollar signs in-tact for variable substitution.
return safeWidgetUrl.replace(/%24/g, '$');
}
_getTileTitle() {
const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = '';
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
title = this.state.widgetPageTitle;
}
@ -698,9 +438,9 @@ export default class AppTile extends React.Component {
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
this._endWidgetActions().then(() => {
if (this._appFrame.current) {
if (this.iframe) {
// Reload iframe
this._appFrame.current.src = this._getRenderedUrl();
this.iframe.src = this._sgWidget.embedUrl;
this.setState({});
}
});
@ -708,13 +448,13 @@ export default class AppTile extends React.Component {
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'),
{ target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click();
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click();
}
_onReloadWidgetClick() {
// Reload iframe in this way to avoid cross-origin restrictions
// eslint-disable-next-line no-self-assign
this._appFrame.current.src = this._appFrame.current.src;
this.iframe.src = this.iframe.src;
}
_onContextMenuClick = () => {
@ -760,7 +500,7 @@ export default class AppTile extends React.Component {
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this.state.widgetUrl}
url={this._sgWidget.embedUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
/>
@ -785,11 +525,11 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement }
<iframe
allow={iframeFeatures}
ref={this._appFrame}
src={this._getRenderedUrl()}
ref={this._iframeRefChange}
src={this._sgWidget.embedUrl}
allowFullScreen={true}
sandbox={sandboxFlags}
onLoad={this._onLoaded} />
/>
</div>
);
// if the widget would be allowed to remain on screen, we must put it in
@ -833,9 +573,10 @@ export default class AppTile extends React.Component {
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
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 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');
contextMenu = (
@ -943,9 +684,6 @@ AppTile.propTypes = {
// NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events.
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
userWidget: PropTypes.bool,
};

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,15 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import React, {ReactChildren, useEffect} from 'react';
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 { _t } from '../../../languageHandler';
import {MatrixEvent, RoomMember} from "matrix-js-sdk";
import {useStateToggle} from "../../../hooks/useStateToggle";
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);
// 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;

View file

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

View file

@ -16,32 +16,60 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, { ReactChildren } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import * as sdk from "../../../index";
import {MatrixEvent} from "matrix-js-sdk";
import { isValid3pidInvite } from "../../../RoomInvite";
import EventListSummary from "./EventListSummary";
export default class MemberEventListSummary extends React.Component {
static propTypes = {
interface IProps {
// An array of member events to summarise
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
// An array of EventTiles to render when expanded
children: PropTypes.array.isRequired,
events: MatrixEvent[];
// 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
avatarsMaxLength: PropTypes.number,
avatarsMaxLength?: number;
// The minimum number of events needed to trigger summarisation
threshold: PropTypes.number,
// Called when the MELS expansion is toggled
onToggle: PropTypes.func,
threshold?: number,
// 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 = {
summaryLength: 1,
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
* 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.
* @param {string[]} orderedTransitionSequences an array which is some ordering of
* `Object.keys(eventAggregates)`.
* @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 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"
// transitions
const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions);
// Transform into consecutive repetitions of the same transition (like 5
// consecutive 'joined_and_left's)
const coalescedTransitions = this._coalesceRepeatedTransitions(
canonicalTransitions,
);
const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
const descs = coalescedTransitions.map((t) => {
return this._getDescriptionForTransition(
return MemberEventListSummary.getDescriptionForTransition(
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
* included before "and [n] others".
*/
_renderNameList(users) {
private renderNameList(users: string[]) {
return formatCommaSeparatedList(users, this.props.summaryLength);
}
@ -119,22 +145,22 @@ export default class MemberEventListSummary extends React.Component {
* @param {string[]} transitions an array of transitions.
* @returns {string[]} an array of transitions.
*/
_getCanonicalTransitions(transitions) {
private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
const modMap = {
'joined': {
'after': 'left',
'newTransition': 'joined_and_left',
[TransitionType.Joined]: {
after: TransitionType.Left,
newTransition: TransitionType.JoinedAndLeft,
},
'left': {
'after': 'joined',
'newTransition': 'left_and_joined',
[TransitionType.Left]: {
after: TransitionType.Joined,
newTransition: TransitionType.LeftAndJoined,
},
// $currentTransition : {
// 'after' : $nextTransition,
// 'newTransition' : 'new_transition_type',
// },
};
const res = [];
const res: TransitionType[] = [];
for (let i = 0; i < transitions.length; 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.
* @returns {object[]} an array of coalesced transitions.
*/
_coalesceRepeatedTransitions(transitions) {
const res = [];
private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
const res: {
transitionType: TransitionType;
repeats: number;
}[] = [];
for (let i = 0; i < transitions.length; i++) {
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
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.
* @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'
// are there only to show translators to non-English languages
// that the verb is conjugated to plural or singular Subject.
@ -217,12 +247,18 @@ export default class MemberEventListSummary extends React.Component {
break;
case "invite_reject":
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 });
break;
case "invite_withdrawal":
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 });
break;
case "invited":
@ -265,8 +301,8 @@ export default class MemberEventListSummary extends React.Component {
return res;
}
_getTransitionSequence(events) {
return events.map(this._getTransition);
private static getTransitionSequence(events: MatrixEvent[]) {
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`
* if a transition is not recognised.
*/
_getTransition(e) {
private static getTransition(e: MatrixEvent): TransitionType {
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
// Handle 3pid invites the same as invites so they get bundled together
if (!isValid3pidInvite(e.mxEvent)) {
return 'invite_withdrawal';
return TransitionType.InviteWithdrawal;
}
return 'invited';
return TransitionType.Invited;
}
switch (e.mxEvent.getContent().membership) {
case 'invite': return 'invited';
case 'ban': return 'banned';
case 'invite': return TransitionType.Invited;
case 'ban': return TransitionType.Banned;
case 'join':
if (e.mxEvent.getPrevContent().membership === 'join') {
if (e.mxEvent.getContent().displayname !==
e.mxEvent.getPrevContent().displayname) {
return 'changed_name';
return TransitionType.ChangedName;
} else if (e.mxEvent.getContent().avatar_url !==
e.mxEvent.getPrevContent().avatar_url) {
return 'changed_avatar';
return TransitionType.ChangedAvatar;
}
// console.log("MELS ignoring duplicate membership join event");
return 'no_change';
return TransitionType.NoChange;
} else {
return 'joined';
return TransitionType.Joined;
}
case 'leave':
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return 'invite_reject';
default: return 'left';
case 'invite': return TransitionType.InviteReject;
default: return TransitionType.Left;
}
}
switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return 'invite_withdrawal';
case 'ban': return 'unbanned';
case 'invite': return TransitionType.InviteWithdrawal;
case 'ban': return TransitionType.Unbanned;
// 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;
}
}
_getAggregate(userEvents) {
getAggregate(userEvents: Record<string, IUserEvents[]>) {
// 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".
// The array of display names is the array of users who went through that
// sequence during eventsToRender.
const aggregate = {
const aggregate: Record<string, string[]> = {
// $aggregateType : []:string
};
// A map of aggregate types to the indices that order them (the index of
// the first event for a given transition sequence)
const aggregateIndices = {
const aggregateIndices: Record<string, number> = {
// $aggregateType : int
};
@ -340,7 +376,7 @@ export default class MemberEventListSummary extends React.Component {
const firstEvent = userEvents[userId][0];
const displayName = firstEvent.displayName;
const seq = this._getTransitionSequence(userEvents[userId]);
const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
if (!aggregate[seq]) {
aggregate[seq] = [];
aggregateIndices[seq] = -1;
@ -349,7 +385,8 @@ export default class MemberEventListSummary extends React.Component {
aggregate[seq].push(displayName);
if (aggregateIndices[seq] === -1 ||
firstEvent.index < aggregateIndices[seq]) {
firstEvent.index < aggregateIndices[seq]
) {
aggregateIndices[seq] = firstEvent.index;
}
},
@ -364,25 +401,21 @@ export default class MemberEventListSummary extends React.Component {
render() {
const eventsToRender = this.props.events;
// Map user IDs to an array of objects:
const userEvents = {
// $userId : [{
// // 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,
// }]
};
// Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
// so this works perfectly for us to match event order whilst storing the latest Avatar Member
const latestUserAvatarMember = new Map<string, RoomMember>();
const avatarMembers = [];
// Object mapping user IDs to an array of IUserEvents
const userEvents: Record<string, IUserEvents[]> = {};
eventsToRender.forEach((e, index) => {
const userId = e.getStateKey();
// Initialise a user's events
if (!userEvents[userId]) {
userEvents[userId] = [];
if (e.target) avatarMembers.push(e.target);
}
if (e.target) {
latestUserAvatarMember.set(userId, e.target);
}
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
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
events={this.props.events}
threshold={this.props.threshold}
onToggle={this.props.onToggle}
startExpanded={this.props.startExpanded}
children={this.props.children}
summaryMembers={avatarMembers}
summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />;
summaryMembers={[...latestUserAvatarMember.values()]}
summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
}
}

View file

@ -58,6 +58,11 @@ export default class PersistentApp extends React.Component {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
if (this.state.roomId !== persistentWidgetInRoomId) {
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
// Sanity check the room - the widget may have been destroyed between render cycles, and
// thus no room is associated anymore.
if (!persistentWidgetInRoom) return null;
// get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
@ -82,6 +87,7 @@ export default class PersistentApp extends React.Component {
showDelete={false}
showMinimise={false}
miniMode={true}
showMenubar={false}
/>;
}
}

View file

@ -29,6 +29,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html";
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
// 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,
};
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.canCollapse = this.canCollapse.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
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",
},
);
@ -213,11 +223,6 @@ export default class ReplyThread extends React.Component {
}
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();
}
@ -227,21 +232,36 @@ export default class ReplyThread extends React.Component {
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener("Event.replaced", this.onEventReplaced);
if (this.room) {
this.room.removeListener("Room.redaction", this.onRoomRedaction);
this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
}
}
onRoomRedaction = (ev, room) => {
if (this.unmounted) return;
// If one of the events we are rendering gets redacted, force a re-render
if (this.state.events.some(event => event.getId() === ev.getId())) {
updateForEventId = (eventId) => {
if (this.state.events.some(event => event.getId() === eventId)) {
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() {
const {parentEv} = this.props;
// 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")}
useIRCLayout={this.props.useIRCLayout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
/>
</blockquote>;
});

View file

@ -26,12 +26,12 @@ import * as FormattingUtils from '../../../utils/FormattingUtils';
import FlairStore from '../../../stores/FlairStore';
import GroupStore from '../../../stores/GroupStore';
import TagOrderStore from '../../../stores/TagOrderStore';
import GroupFilterOrderStore from '../../../stores/GroupFilterOrderStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton from "./AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
// A class for a child of GroupFilterPanel (possibly wrapped in a DNDTagTile) that represents
// a thing to click on for the user to filter the visible rooms in the RoomList to:
// - Rooms that are part of the group
// - Direct messages with members of the group
@ -142,7 +142,7 @@ export default class TagTile extends React.Component {
mx_TagTile_selected_prototype: this.props.selected && isPrototype,
});
const badge = TagOrderStore.getGroupBadge(this.props.tag);
const badge = GroupFilterOrderStore.getGroupBadge(this.props.tag);
let badgeElement;
if (badge && !this.state.hover && !this.props.menuDisplayed) {
const badgeClasses = classNames({

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import * as fbEmitter from "fbemitter";
import TagOrderStore from "../../../stores/TagOrderStore";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
import AccessibleTooltipButton from "./AccessibleTooltipButton";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
@ -36,12 +36,12 @@ export default class UserTagTile extends React.PureComponent<IProps, IState> {
super(props);
this.state = {
selected: TagOrderStore.getSelectedTags().length === 0,
selected: GroupFilterOrderStore.getSelectedTags().length === 0,
};
}
public componentDidMount() {
this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
}
public componentWillUnmount() {
@ -49,7 +49,7 @@ export default class UserTagTile extends React.PureComponent<IProps, IState> {
}
private onTagStoreUpdate = () => {
const selected = TagOrderStore.getSelectedTags().length === 0;
const selected = GroupFilterOrderStore.getSelectedTags().length === 0;
this.setState({selected});
};

View file

@ -1,5 +1,6 @@
/*
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");
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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, {RefObject} from 'react';
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;
class Category extends React.PureComponent {
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),
};
export type CategoryKey = (keyof typeof DATA_BY_CATEGORY) | "recent";
_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 emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
const Emoji = sdk.getComponent("emojipicker.Emoji");
return (<div key={rowIndex}>{
emojisForRow.map(emoji =>
<Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis}
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />)
emojisForRow.map(emoji => ((
<Emoji
key={emoji.hexcode}
emoji={emoji}
selectedEmojis={selectedEmojis}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
)))
}</div>);
};
@ -52,7 +74,6 @@ class Category extends React.PureComponent {
for (let counter = 0; counter < rows.length; ++counter) {
rows[counter] = counter;
}
const LazyRenderList = sdk.getComponent('elements.LazyRenderList');
const viewportTop = scrollTop;
const viewportBottom = viewportTop + viewportHeight;
@ -84,7 +105,7 @@ class Category extends React.PureComponent {
height={localHeight}
overflowItems={OVERFLOW_ROWS}
overflowMargin={0}
renderItem={this._renderEmojiRow}>
renderItem={this.renderEmojiRow}>
</LazyRenderList>
</section>
);

View file

@ -1,5 +1,6 @@
/*
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");
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 PropTypes from 'prop-types';
import {MenuItem} from "../../structures/ContextMenu";
import {IEmoji} from "../../../emoji";
class Emoji extends React.PureComponent {
static propTypes = {
onClick: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
emoji: PropTypes.object.isRequired,
selectedEmojis: PropTypes.instanceOf(Set),
};
interface IProps {
emoji: IEmoji;
selectedEmojis?: Set<string>;
onClick(emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void;
}
class Emoji extends React.PureComponent<IProps> {
render() {
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);

View file

@ -1,5 +1,6 @@
/*
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");
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 PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
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 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 EMOJI_HEIGHT = 37;
export const EMOJIS_PER_ROW = 8;
class EmojiPicker extends React.Component {
static propTypes = {
onChoose: PropTypes.func.isRequired,
selectedEmojis: PropTypes.instanceOf(Set),
showQuickReactions: PropTypes.bool,
};
interface IProps {
selectedEmojis: Set<string>;
showQuickReactions?: boolean;
onChoose(unicode: string): boolean;
}
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) {
super(props);
@ -42,9 +61,6 @@ class EmojiPicker extends React.Component {
filter: "",
previewEmoji: null,
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,
};
@ -110,18 +126,9 @@ class EmojiPicker extends React.Component {
visible: false,
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;
this.setState({
scrollTop: body.scrollTop,
@ -130,7 +137,7 @@ class EmojiPicker extends React.Component {
this.updateVisibility();
};
updateVisibility() {
private updateVisibility = () => {
const body = this.bodyRef.current;
const rect = body.getBoundingClientRect();
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.
if (cat.visible) {
cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
cat.ref.current.setAttribute("aria-selected", true);
cat.ref.current.setAttribute("tabindex", 0);
cat.ref.current.setAttribute("aria-selected", "true");
cat.ref.current.setAttribute("tabindex", "0");
} else {
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
cat.ref.current.setAttribute("aria-selected", false);
cat.ref.current.setAttribute("tabindex", -1);
}
cat.ref.current.setAttribute("aria-selected", "false");
cat.ref.current.setAttribute("tabindex", "-1");
}
}
};
scrollToCategory(category) {
private scrollToCategory = (category: string) => {
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
for (const cat of this.categories) {
let emojis;
@ -181,27 +188,34 @@ class EmojiPicker extends React.Component {
// Header underlines need to be updated, but updating requires knowing
// where the categories are, so we wait for a tick.
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({
previewEmoji: emoji,
});
}
};
onHoverEmojiEnd(emoji) {
private onHoverEmojiEnd = (emoji: IEmoji) => {
this.setState({
previewEmoji: null,
});
}
};
onClickEmoji(emoji) {
private onClickEmoji = (emoji: IEmoji) => {
if (this.props.onChoose(emoji.unicode) !== false) {
recent.add(emoji.unicode);
}
}
};
_categoryHeightForEmojiCount(count) {
private static categoryHeightForEmojiCount(count: number) {
if (count === 0) {
return 0;
}
@ -209,25 +223,37 @@ class EmojiPicker extends React.Component {
}
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;
return (
<div className="mx_EmojiPicker">
<Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} />
<Search query={this.state.filter} onChange={this.onChangeFilter} />
<AutoHideScrollbar className="mx_EmojiPicker_body" wrappedRef={e => this.bodyRef.current = e} onScroll={this.onScroll}>
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
<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 => {
const emojis = this.memoizedDataByCategory[category.id];
const categoryElement = (<Category key={category.id} id={category.id} name={category.name}
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 = this._categoryHeightForEmojiCount(emojis.length);
const categoryElement = ((
<Category
key={category.id}
id={category.id}
name={category.name}
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;
return categoryElement;
})}

View file

@ -1,5 +1,6 @@
/*
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");
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 PropTypes from 'prop-types';
import classNames from "classnames";
import {_t} from "../../../languageHandler";
import {Key} from "../../../Keyboard";
import {CategoryKey, ICategory} from "./Category";
class Header extends React.PureComponent {
static propTypes = {
categories: PropTypes.arrayOf(PropTypes.object).isRequired,
onAnchorClick: PropTypes.func.isRequired,
};
interface IProps {
categories: ICategory[];
onAnchorClick(id: CategoryKey): void
}
findNearestEnabled(index, delta) {
class Header extends React.PureComponent<IProps> {
private findNearestEnabled(index: number, delta: number) {
index += this.props.categories.length;
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);
this.changeCategoryAbsolute(current + delta, delta);
}
changeCategoryAbsolute(index, delta=1) {
private changeCategoryAbsolute(index: number, delta=1) {
const category = this.props.categories[this.findNearestEnabled(index, delta)];
if (category) {
this.props.onAnchorClick(category.id);
@ -52,7 +53,7 @@ class Header extends React.PureComponent {
// Implements ARIA Tabs with Automatic Activation pattern
// https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
onKeyDown = (ev) => {
private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true;
switch (ev.key) {
case Key.ARROW_LEFT:
@ -80,7 +81,12 @@ class Header extends React.PureComponent {
render() {
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 => {
const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, {
mx_EmojiPicker_anchor_visible: category.visible,

View file

@ -1,5 +1,6 @@
/*
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");
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 PropTypes from 'prop-types';
class Preview extends React.PureComponent {
static propTypes = {
emoji: PropTypes.object,
};
import {IEmoji} from "../../../emoji";
interface IProps {
emoji: IEmoji;
}
class Preview extends React.PureComponent<IProps> {
render() {
const {
unicode = "",
annotation = "",
shortcodes: [shortcode = ""],
} = this.props.emoji || {};
return (
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
<div className="mx_EmojiPicker_preview_emoji">

View file

@ -1,5 +1,6 @@
/*
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");
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 PropTypes from 'prop-types';
import * as sdk from '../../../index';
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
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
@ -30,36 +30,36 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
return data;
});
class QuickReactions extends React.Component {
static propTypes = {
onClick: PropTypes.func.isRequired,
selectedEmojis: PropTypes.instanceOf(Set),
};
interface IProps {
selectedEmojis?: Set<string>;
onClick(emoji: IEmoji): void;
}
interface IState {
hover?: IEmoji;
}
class QuickReactions extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
hover: null,
};
this.onMouseEnter = this.onMouseEnter.bind(this);
this.onMouseLeave = this.onMouseLeave.bind(this);
}
onMouseEnter(emoji) {
private onMouseEnter = (emoji: IEmoji) => {
this.setState({
hover: emoji,
});
}
};
onMouseLeave() {
private onMouseLeave = () => {
this.setState({
hover: null,
});
}
};
render() {
const Emoji = sdk.getComponent("emojipicker.Emoji");
return (
<section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category">
<h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label">
@ -72,10 +72,16 @@ class QuickReactions extends React.Component {
}
</h2>
<ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
{QUICK_REACTIONS.map(emoji => <Emoji
key={emoji.hexcode} emoji={emoji} onClick={this.props.onClick}
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}
selectedEmojis={this.props.selectedEmojis} />)}
{QUICK_REACTIONS.map(emoji => ((
<Emoji
key={emoji.hexcode}
emoji={emoji}
onClick={this.props.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
selectedEmojis={this.props.selectedEmojis}
/>
)))}
</ul>
</section>
);

View file

@ -1,5 +1,6 @@
/*
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");
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 PropTypes from "prop-types";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import EmojiPicker from "./EmojiPicker";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
class ReactionPicker extends React.Component {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
reactions: PropTypes.object,
};
interface IProps {
mxEvent: MatrixEvent;
reactions: any; // TODO type this once js-sdk is more typescripted
onFinished(): void;
}
interface IState {
selectedEmojis: Set<string>;
}
class ReactionPicker extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
selectedEmojis: new Set(Object.keys(this.getReactions())),
};
this.onChoose = this.onChoose.bind(this);
this.onReactionsChange = this.onReactionsChange.bind(this);
this.addListeners();
}
@ -45,7 +49,7 @@ class ReactionPicker extends React.Component {
}
}
addListeners() {
private addListeners() {
if (this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange);
@ -55,22 +59,13 @@ class ReactionPicker extends React.Component {
componentWillUnmount() {
if (this.props.reactions) {
this.props.reactions.removeListener(
"Relations.add",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
this.props.reactions.removeListener("Relations.add", 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) {
return {};
}
@ -81,13 +76,13 @@ class ReactionPicker extends React.Component {
.map(event => [event.getRelation().key, event.getId()]));
}
onReactionsChange() {
private onReactionsChange = () => {
this.setState({
selectedEmojis: new Set(Object.keys(this.getReactions())),
});
}
};
onChoose(reaction) {
onChoose = (reaction: string) => {
this.componentWillUnmount();
this.props.onFinished();
const myReactions = this.getReactions();
@ -109,7 +104,7 @@ class ReactionPicker extends React.Component {
dis.dispatch({action: "message_sent"});
return true;
}
}
};
render() {
return <EmojiPicker

View file

@ -1,5 +1,6 @@
/*
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");
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 PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {Key} from "../../../Keyboard";
class Search extends React.PureComponent {
static propTypes = {
query: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.inputRef = React.createRef();
interface IProps {
query: string;
onChange(value: string): void;
onEnter(): void;
}
class Search extends React.PureComponent<IProps> {
private inputRef = React.createRef<HTMLInputElement>();
componentDidMount() {
// For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout
setTimeout(() => this.inputRef.current.focus(), 0);
}
private onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === Key.ENTER) {
this.props.onEnter();
ev.stopPropagation();
ev.preventDefault();
}
};
render() {
let rightButton;
if (this.props.query) {
rightButton = (
<button onClick={() => this.props.onChange("")}
<button
onClick={() => this.props.onChange("")}
className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
title={_t("Cancel search")} />
title={_t("Cancel search")}
/>
);
} else {
rightButton = <span className="mx_EmojiPicker_search_icon" />;
@ -48,8 +58,15 @@ class Search extends React.PureComponent {
return (
<div className="mx_EmojiPicker_search">
<input autoFocus type="text" placeholder="Search" value={this.props.query}
onChange={ev => this.props.onChange(ev.target.value)} ref={this.inputRef} />
<input
autoFocus
type="text"
placeholder="Search"
value={this.props.query}
onChange={ev => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown}
ref={this.inputRef}
/>
{rightButton}
</div>
);

View file

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

@ -45,7 +45,7 @@ export default class RoomCreate extends React.Component {
render() {
const predecessor = this.props.mxEvent.getContent()['predecessor'];
if (predecessor === undefined) {
return <div />; // We should never have been instaniated in this case
return <div />; // We should never have been instantiated in this case
}
const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']);
const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor['room_id']);

View file

@ -401,7 +401,8 @@ export default class TextualBody extends React.Component {
const mxEvent = this.props.mxEvent;
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, {
disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
// Part of Replies fallback support

View file

@ -31,6 +31,7 @@ interface IProps {
className?: string;
withoutScrollContainer?: boolean;
previousPhase?: RightPanelPhases;
closeLabel?: string;
onClose?(): void;
}
@ -47,6 +48,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
};
const BaseCard: React.FC<IProps> = ({
closeLabel,
onClose,
className,
header,
@ -68,7 +70,11 @@ const BaseCard: React.FC<IProps> = ({
let closeButton;
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) {

View file

@ -27,6 +27,9 @@ import * as sdk from "../../../index";
import {_t} from "../../../languageHandler";
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
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
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 {verificationRequest, verificationRequestPromise, member, onClose, layout, isRoomEncrypted} = props;
const {
verificationRequest,
verificationRequestPromise,
member,
onClose,
layout,
isRoomEncrypted,
} = props;
const [request, setRequest] = useState(verificationRequest);
// state to show a spinner immediately after clicking "start verification",
// before we have a request
@ -95,22 +105,6 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
}, [onClose, request]);
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 () => {
setRequesting(true);
const cli = MatrixClientPeg.get();
@ -118,7 +112,13 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId);
setRequest(verificationRequest_);
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 =
(!request && isRequesting) ||
@ -128,8 +128,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
member.userId === MatrixClientPeg.get().getUserId();
if (!request || requested) {
const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe);
return (<React.Fragment>
{cancelButton}
return (
<EncryptionInfo
isRoomEncrypted={isRoomEncrypted}
onStartVerification={onStartVerification}
@ -138,10 +137,9 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
waitingForOtherParty={requested && initiatedByMe}
waitingForNetwork={requested && !initiatedByMe}
inDialog={layout === "dialog"} />
</React.Fragment>);
);
} else {
return (<React.Fragment>
{cancelButton}
return (
<VerificationPanel
isRoomEncrypted={isRoomEncrypted}
layout={layout}
@ -152,7 +150,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
inDialog={layout === "dialog"}
phase={phase}
/>
</React.Fragment>);
);
}
};

View file

@ -31,7 +31,7 @@ interface IProps {
// The badge to display above the icon
badge?: React.ReactNode;
// The parameters to track the click event
analytics: string[];
analytics: Parameters<typeof Analytics.trackEvent>;
// Button name
name: string;

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