diff --git a/.babelrc b/.babelrc index 3fb847ad18..abe7e1ef3f 100644 --- a/.babelrc +++ b/.babelrc @@ -13,7 +13,6 @@ ], "transform-class-properties", "transform-object-rest-spread", - "transform-async-to-bluebird", "transform-runtime", "add-module-exports", "syntax-dynamic-import" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c46530fad..8fe6f80e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,124 @@ +Changes in [1.7.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.4) (2019-11-27) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.3...v1.7.4) + +* Upgrade to JS SDK 2.5.4 to relax identity server discovery and E2EE debugging +* Fix override behaviour of system vs defined theme +* Clarify that cross-signing is in development + +Changes in [1.7.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.3) (2019-11-25) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.3-rc.2...v1.7.3) + + * No changes since rc.2 + +Changes in [1.7.3-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.3-rc.2) (2019-11-22) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.3-rc.1...v1.7.3-rc.2) + + * Fix double date separator for room upgrade tiles + [\#3663](https://github.com/matrix-org/matrix-react-sdk/pull/3663) + * Show m.room.create event before the ELS on room upgrade + [\#3660](https://github.com/matrix-org/matrix-react-sdk/pull/3660) + * Make addEventListener conditional + [\#3659](https://github.com/matrix-org/matrix-react-sdk/pull/3659) + * Fix e2e icons + [\#3658](https://github.com/matrix-org/matrix-react-sdk/pull/3658) + +Changes in [1.7.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.3-rc.1) (2019-11-20) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.2...v1.7.3-rc.1) + + * Fix positioning, size, and colour of the composer e2e icon + [\#3641](https://github.com/matrix-org/matrix-react-sdk/pull/3641) + * upgrade nunito from 3.500 to 3.504 + [\#3639](https://github.com/matrix-org/matrix-react-sdk/pull/3639) + * Wire up the widget permission prompt to the cross-platform setting + [\#3630](https://github.com/matrix-org/matrix-react-sdk/pull/3630) + * Get theme automatically from system setting + [\#3637](https://github.com/matrix-org/matrix-react-sdk/pull/3637) + * Update code style for our 90 char life + [\#3636](https://github.com/matrix-org/matrix-react-sdk/pull/3636) + * use general warning icon instead of e2e one for room status + [\#3633](https://github.com/matrix-org/matrix-react-sdk/pull/3633) + * Add support for platform specific event indexing and search + [\#3550](https://github.com/matrix-org/matrix-react-sdk/pull/3550) + * Update from Weblate + [\#3635](https://github.com/matrix-org/matrix-react-sdk/pull/3635) + * Use a settings watcher to set the theme + [\#3634](https://github.com/matrix-org/matrix-react-sdk/pull/3634) + * Merge the `feature_user_info_panel` flag into `feature_dm_verification` + [\#3632](https://github.com/matrix-org/matrix-react-sdk/pull/3632) + * Fix some styling regressions in member panel + [\#3631](https://github.com/matrix-org/matrix-react-sdk/pull/3631) + * Add a bit more safety around breadcrumbs + [\#3629](https://github.com/matrix-org/matrix-react-sdk/pull/3629) + * Ensure widgets always have a sender associated with them + [\#3628](https://github.com/matrix-org/matrix-react-sdk/pull/3628) + * re-add missing case of codepath + [\#3627](https://github.com/matrix-org/matrix-react-sdk/pull/3627) + * Implement the bulk of the new widget permission prompt design + [\#3622](https://github.com/matrix-org/matrix-react-sdk/pull/3622) + * Relax identity server discovery error handling + [\#3588](https://github.com/matrix-org/matrix-react-sdk/pull/3588) + * Add cross-signing feature flag + [\#3626](https://github.com/matrix-org/matrix-react-sdk/pull/3626) + * Attempt number two at ripping out Bluebird from rageshake.js + [\#3624](https://github.com/matrix-org/matrix-react-sdk/pull/3624) + * Update from Weblate + [\#3625](https://github.com/matrix-org/matrix-react-sdk/pull/3625) + * Remove Bluebird: phase 2.1 + [\#3618](https://github.com/matrix-org/matrix-react-sdk/pull/3618) + * Add better error handling to Synapse user deactivation + [\#3619](https://github.com/matrix-org/matrix-react-sdk/pull/3619) + * New design for member panel + [\#3620](https://github.com/matrix-org/matrix-react-sdk/pull/3620) + * Show server details on login for unreachable homeserver + [\#3617](https://github.com/matrix-org/matrix-react-sdk/pull/3617) + * Add a function to get the "base" theme for a theme + [\#3615](https://github.com/matrix-org/matrix-react-sdk/pull/3615) + * Remove Bluebird: phase 2 + [\#3616](https://github.com/matrix-org/matrix-react-sdk/pull/3616) + * Remove Bluebird: phase 1 + [\#3612](https://github.com/matrix-org/matrix-react-sdk/pull/3612) + * Move notification count to in front of the room name in the page title + [\#3613](https://github.com/matrix-org/matrix-react-sdk/pull/3613) + * Add some logging/recovery for lost rooms + [\#3614](https://github.com/matrix-org/matrix-react-sdk/pull/3614) + * Add Mjolnir ban list support + [\#3585](https://github.com/matrix-org/matrix-react-sdk/pull/3585) + * Improve room switching performance with alias cache + [\#3610](https://github.com/matrix-org/matrix-react-sdk/pull/3610) + * Fix draw order when hovering composer format buttons + [\#3609](https://github.com/matrix-org/matrix-react-sdk/pull/3609) + * Use a ternary operator instead of relying on AND semantics in + EditHistoryDialog + [\#3606](https://github.com/matrix-org/matrix-react-sdk/pull/3606) + * Update from Weblate + [\#3608](https://github.com/matrix-org/matrix-react-sdk/pull/3608) + * Fix HTML fallback in replies + [\#3607](https://github.com/matrix-org/matrix-react-sdk/pull/3607) + * Fix rounded corners for the formatting toolbar + [\#3605](https://github.com/matrix-org/matrix-react-sdk/pull/3605) + * Check for a message type before assuming it is a room message + [\#3604](https://github.com/matrix-org/matrix-react-sdk/pull/3604) + * Remove lint comments about no-descending-specificity + [\#3603](https://github.com/matrix-org/matrix-react-sdk/pull/3603) + * Show verification requests in the timeline + [\#3601](https://github.com/matrix-org/matrix-react-sdk/pull/3601) + * Match identity server registration to the IS r0.3.0 spec + [\#3602](https://github.com/matrix-org/matrix-react-sdk/pull/3602) + * Restore thumbs after variation selector removal + [\#3600](https://github.com/matrix-org/matrix-react-sdk/pull/3600) + * Fix breadcrumbs so the bar is a toolbar and the buttons are buttons. + [\#3599](https://github.com/matrix-org/matrix-react-sdk/pull/3599) + * Now that part of spacing is padding, make it smaller when collapsed + [\#3597](https://github.com/matrix-org/matrix-react-sdk/pull/3597) + * Remove variation selectors from quick reactions + [\#3598](https://github.com/matrix-org/matrix-react-sdk/pull/3598) + * Fix linkify imports + [\#3595](https://github.com/matrix-org/matrix-react-sdk/pull/3595) + Changes in [1.7.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.2) (2019-11-06) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.1...v1.7.2) diff --git a/code_style.md b/code_style.md index e7844b939c..4b2338064c 100644 --- a/code_style.md +++ b/code_style.md @@ -22,7 +22,7 @@ number throgh from the original code to the final application. General Style ------------- - 4 spaces to indent, for consistency with Matrix Python. -- 120 columns per line, but try to keep JavaScript code around the 80 column mark. +- 120 columns per line, but try to keep JavaScript code around the 90 column mark. Inline JSX in particular can be nicer with more columns per line. - No trailing whitespace at end of lines. - Don't indent empty lines. diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md index e67c74a95c..00033b5b8c 100644 --- a/docs/ciderEditor.md +++ b/docs/ciderEditor.md @@ -2,8 +2,7 @@ The CIDER editor is a custom editor written for Riot. Most of the code can be found in the `/editor/` directory of the `matrix-react-sdk` project. -It is used to power the composer to edit messages, -and will soon be used as the main composer to send messages as well. +It is used to power the composer main composer (both to send and edit messages), and might be used for other usecases where autocomplete is desired (invite box, ...). ## High-level overview. diff --git a/package.json b/package.json index 95b294c8a5..5b82d9b111 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "1.7.2", + "version": "1.7.4", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -60,7 +60,6 @@ "dependencies": { "babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-runtime": "^6.26.0", - "bluebird": "^3.5.0", "blueimp-canvas-to-blob": "^3.5.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", @@ -82,13 +81,14 @@ "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566", "gfm.css": "^1.1.1", "glob": "^5.0.14", + "glob-to-regexp": "^0.4.1", "highlight.js": "^9.15.8", "is-ip": "^2.0.0", "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.6", "lodash": "^4.17.14", "lolex": "4.2", - "matrix-js-sdk": "2.4.3", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "pako": "^1.0.5", "png-chunks-extract": "^1.0.0", @@ -119,7 +119,6 @@ "babel-eslint": "^10.0.1", "babel-loader": "^7.1.5", "babel-plugin-add-module-exports": "^0.2.1", - "babel-plugin-transform-async-to-bluebird": "^1.1.1", "babel-plugin-transform-builtin-extend": "^1.1.2", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-object-rest-spread": "^6.26.0", @@ -135,6 +134,7 @@ "eslint-config-google": "^0.7.1", "eslint-plugin-babel": "^5.2.1", "eslint-plugin-flowtype": "^2.30.0", + "eslint-plugin-jest": "^23.0.4", "eslint-plugin-react": "^7.7.0", "eslint-plugin-react-hooks": "^2.0.1", "estree-walker": "^0.5.0", diff --git a/res/css/_common.scss b/res/css/_common.scss index 70ab2457f1..51d985efb7 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -30,6 +30,11 @@ body { color: $primary-fg-color; border: 0px; margin: 0px; + + // needed to match the designs correctly on macOS + // see https://github.com/vector-im/riot-web/issues/11425 + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } pre, code { @@ -550,6 +555,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { color: $username-variant8-color; } +@define-mixin mx_Tooltip_dark { + box-shadow: none; + background-color: $tooltip-timeline-bg-color; + color: $tooltip-timeline-fg-color; + border: none; + border-radius: 3px; + padding: 6px 8px; +} + +// This is a workaround for our mixins not supporting child selectors +.mx_Tooltip_dark { + .mx_Tooltip_chevron::after { + border-right-color: $tooltip-timeline-bg-color; + } +} + @define-mixin mx_Settings_fullWidthField { margin-right: 100px; } diff --git a/res/css/_components.scss b/res/css/_components.scss index 5d26185393..c47222da59 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -25,6 +25,7 @@ @import "./structures/_TabbedView.scss"; @import "./structures/_TagPanel.scss"; @import "./structures/_TagPanelButtons.scss"; +@import "./structures/_ToastContainer.scss"; @import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_ViewSource.scss"; @@ -48,6 +49,7 @@ @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; @import "./views/context_menus/_TopLeftMenu.scss"; +@import "./views/context_menus/_WidgetContextMenu.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @@ -90,6 +92,8 @@ @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_Field.scss"; +@import "./views/elements/_FormButton.scss"; +@import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InteractiveTooltip.scss"; @@ -124,6 +128,7 @@ @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; +@import "./views/messages/_MjolnirBody.scss"; @import "./views/messages/_ReactionsRow.scss"; @import "./views/messages/_ReactionsRowButton.scss"; @import "./views/messages/_ReactionsRowButtonTooltip.scss"; @@ -170,7 +175,7 @@ @import "./views/rooms/_WhoIsTypingTile.scss"; @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; -@import "./views/settings/_IntegrationsManager.scss"; +@import "./views/settings/_IntegrationManager.scss"; @import "./views/settings/_KeyBackupPanel.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @@ -183,6 +188,7 @@ @import "./views/settings/tabs/room/_SecurityRoomSettingsTab.scss"; @import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.scss"; +@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss"; @import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 50d412ad58..5e826306c6 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -221,6 +221,9 @@ hr.mx_RoomView_myReadMarker { position: relative; top: -1px; z-index: 1; + transition: width 400ms easeInSine 1s, opacity 400ms easeInSine 1s; + width: 99%; + opacity: 1; } .mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss new file mode 100644 index 0000000000..4c5e746e66 --- /dev/null +++ b/res/css/structures/_ToastContainer.scss @@ -0,0 +1,98 @@ +/* +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. +*/ + +.mx_ToastContainer { + position: absolute; + top: 0; + left: 70px; + z-index: 101; + padding: 4px; + display: grid; + grid-template-rows: 1fr 14px 6px; + + &.mx_ToastContainer_stacked::before { + content: ""; + margin: 0 4px; + grid-row: 2 / 4; + grid-column: 1; + background-color: white; + box-shadow: 0px 4px 12px $menu-box-shadow-color; + border-radius: 8px; + } + + .mx_Toast_toast { + grid-row: 1 / 3; + grid-column: 1; + color: $primary-fg-color; + background-color: $primary-bg-color; + box-shadow: 0px 4px 12px $menu-box-shadow-color; + border-radius: 8px; + overflow: hidden; + display: grid; + grid-template-columns: 20px 1fr; + column-gap: 10px; + row-gap: 4px; + padding: 8px; + padding-right: 16px; + + &.mx_Toast_hasIcon { + &::after { + content: ""; + width: 20px; + height: 20px; + grid-column: 1; + grid-row: 1; + mask-size: 100%; + mask-repeat: no-repeat; + } + + &.mx_Toast_icon_verification::after { + mask-image: url("$(res)/img/e2e/normal.svg"); + background-color: $primary-fg-color; + } + + h2, .mx_Toast_body { + grid-column: 2; + } + } + + h2 { + grid-column: 1 / 3; + grid-row: 1; + margin: 0; + font-size: 15px; + font-weight: 600; + } + + .mx_Toast_body { + grid-column: 1 / 3; + grid-row: 2; + } + + .mx_Toast_buttons { + display: flex; + } + + .mx_Toast_description { + max-width: 400px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 4px 0 11px 0; + font-size: 12px; + } + } +} diff --git a/res/css/views/context_menus/_WidgetContextMenu.scss b/res/css/views/context_menus/_WidgetContextMenu.scss new file mode 100644 index 0000000000..60b7b93f99 --- /dev/null +++ b/res/css/views/context_menus/_WidgetContextMenu.scss @@ -0,0 +1,36 @@ +/* +Copyright 2019 The Matrix.org Foundaction 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_WidgetContextMenu { + padding: 6px; + + .mx_WidgetContextMenu_option { + padding: 3px 6px 3px 6px; + cursor: pointer; + white-space: nowrap; + } + + .mx_WidgetContextMenu_separator { + margin-top: 0; + margin-bottom: 0; + border-bottom-style: none; + border-left-style: none; + border-right-style: none; + border-top-style: solid; + border-top-width: 1px; + border-color: $menu-border-color; + } +} diff --git a/res/css/views/dialogs/_TermsDialog.scss b/res/css/views/dialogs/_TermsDialog.scss index aad679a5b3..beb507e778 100644 --- a/res/css/views/dialogs/_TermsDialog.scss +++ b/res/css/views/dialogs/_TermsDialog.scss @@ -16,10 +16,10 @@ limitations under the License. /* * To avoid visual glitching of two modals stacking briefly, we customise the - * terms dialog sizing when it will appear for the integrations manager so that + * terms dialog sizing when it will appear for the integration manager so that * it gets the same basic size as the IM's own modal. */ -.mx_TermsDialog_forIntegrationsManager .mx_Dialog { +.mx_TermsDialog_forIntegrationManager .mx_Dialog { width: 60%; height: 70%; box-sizing: border-box; diff --git a/res/css/views/dialogs/_UserSettingsDialog.scss b/res/css/views/dialogs/_UserSettingsDialog.scss index 2a046ff501..4d831d7858 100644 --- a/res/css/views/dialogs/_UserSettingsDialog.scss +++ b/res/css/views/dialogs/_UserSettingsDialog.scss @@ -45,6 +45,10 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/flag.svg'); } +.mx_UserSettingsDialog_mjolnirIcon::before { + mask-image: url('$(res)/img/feather-customised/face.svg'); +} + .mx_UserSettingsDialog_flairIcon::before { mask-image: url('$(res)/img/feather-customised/flair.svg'); } diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index 4d012a136e..b260d4b097 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -49,6 +49,7 @@ limitations under the License. color: $primary-fg-color; background-color: $primary-bg-color; flex: 1; + min-width: 0; } .mx_Field select { diff --git a/res/css/views/elements/_FormButton.scss b/res/css/views/elements/_FormButton.scss new file mode 100644 index 0000000000..1483fe2091 --- /dev/null +++ b/res/css/views/elements/_FormButton.scss @@ -0,0 +1,36 @@ +/* +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. +*/ + +.mx_FormButton { + line-height: 16px; + padding: 5px 15px; + font-size: 12px; + height: min-content; + + &:not(:last-child) { + margin-right: 8px; + } + + &.mx_AccessibleButton_kind_primary { + color: $accent-color; + background-color: $accent-bg-color; + } + + &.mx_AccessibleButton_kind_danger { + color: $notice-primary-color; + background-color: $notice-primary-bg-color; + } +} diff --git a/res/css/views/elements/_IconButton.scss b/res/css/views/elements/_IconButton.scss new file mode 100644 index 0000000000..d8ebbeb65e --- /dev/null +++ b/res/css/views/elements/_IconButton.scss @@ -0,0 +1,55 @@ +/* +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. +*/ + +.mx_IconButton { + width: 32px; + height: 32px; + border-radius: 100%; + background-color: $accent-bg-color; + // don't shrink or grow if in a flex container + flex: 0 0 auto; + + &.mx_AccessibleButton_disabled { + background-color: none; + + &::before { + background-color: lightgrey; + } + } + + &:hover { + opacity: 90%; + } + + &::before { + content: ""; + display: block; + width: 100%; + height: 100%; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 55%; + background-color: $accent-color; + } + + &.mx_IconButton_icon_check::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); + } + + &.mx_IconButton_icon_edit::before { + mask-image: url('$(res)/img/feather-customised/edit.svg'); + } +} diff --git a/res/css/views/messages/_MKeyVerificationRequest.scss b/res/css/views/messages/_MKeyVerificationRequest.scss index aff44e4109..ee20751083 100644 --- a/res/css/views/messages/_MKeyVerificationRequest.scss +++ b/res/css/views/messages/_MKeyVerificationRequest.scss @@ -25,7 +25,7 @@ limitations under the License. width: 12px; height: 16px; content: ""; - mask: url("$(res)/img/e2e/verified.svg"); + mask-image: url("$(res)/img/e2e/normal.svg"); mask-repeat: no-repeat; mask-size: 100%; margin-top: 4px; @@ -33,6 +33,7 @@ limitations under the License. } &.mx_KeyVerification_icon_verified::after { + mask-image: url("$(res)/img/e2e/verified.svg"); background-color: $accent-color; } @@ -64,23 +65,6 @@ limitations under the License. .mx_KeyVerification_buttons { align-items: center; display: flex; - - .mx_AccessibleButton_kind_decline { - color: $notice-primary-color; - background-color: $notice-primary-bg-color; - } - - .mx_AccessibleButton_kind_accept { - color: $accent-color; - background-color: $accent-bg-color; - } - - [role=button] { - margin: 10px; - padding: 7px 15px; - border-radius: 5px; - height: min-content; - } } .mx_KeyVerification_state { diff --git a/res/css/views/messages/_MjolnirBody.scss b/res/css/views/messages/_MjolnirBody.scss new file mode 100644 index 0000000000..2760adfd7e --- /dev/null +++ b/res/css/views/messages/_MjolnirBody.scss @@ -0,0 +1,19 @@ +/* +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. +*/ + +.mx_MjolnirBody { + opacity: 0.4; +} diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index df536a7388..df7d0a5f87 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -20,156 +20,225 @@ limitations under the License. flex-direction: column; flex: 1; overflow-y: auto; -} - -.mx_UserInfo_profile .mx_E2EIcon { - display: inline; - margin: auto; - padding-right: 25px; - mask-size: contain; -} - -.mx_UserInfo_cancel { - height: 16px; - width: 16px; - padding: 10px 0 10px 10px; - cursor: pointer; - mask-image: url('$(res)/img/minimise.svg'); - mask-repeat: no-repeat; - mask-position: 16px center; - background-color: $rightpanel-button-color; -} - -.mx_UserInfo_profile h2 { - flex: 1; - overflow-x: auto; - max-height: 50px; -} - -.mx_UserInfo h2 { - font-size: 16px; - font-weight: 600; - margin: 16px 0 8px 0; -} - -.mx_UserInfo_container { - padding: 0 16px 16px 16px; - border-bottom: 1px solid lightgray; -} - -.mx_UserInfo_memberDetailsContainer { - padding-bottom: 0; -} - -.mx_UserInfo .mx_RoomTile_nameContainer { - width: 154px; -} - -.mx_UserInfo .mx_RoomTile_badge { - display: none; -} - -.mx_UserInfo .mx_RoomTile_name { - width: 160px; -} - -.mx_UserInfo_avatar { - background: $tagpanel-bg-color; -} - -.mx_UserInfo_avatar > img { - height: auto; - width: 100%; - max-height: 30vh; - object-fit: contain; - display: block; -} - -.mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { - cursor: zoom-in; -} - -.mx_UserInfo h3 { - text-transform: uppercase; - color: $input-darker-fg-color; - font-weight: bold; font-size: 12px; - margin: 4px 0; -} -.mx_UserInfo_profileField { - font-size: 15px; - position: relative; - text-align: center; -} - -.mx_UserInfo_memberDetails { - text-align: center; -} - -.mx_UserInfo_field { - cursor: pointer; - font-size: 15px; - color: $primary-fg-color; - margin-left: 8px; - line-height: 23px; -} - -.mx_UserInfo_createRoom { - cursor: pointer; - display: flex; - align-items: center; - padding: 0 8px; -} - -.mx_UserInfo_createRoom_label { - width: initial !important; - cursor: pointer; -} - -.mx_UserInfo_statusMessage { - font-size: 11px; - opacity: 0.5; - overflow: hidden; - white-space: nowrap; - text-overflow: clip; -} -.mx_UserInfo .mx_UserInfo_scrollContainer { - flex: 1; - padding-bottom: 16px; -} - -.mx_UserInfo .mx_UserInfo_scrollContainer .mx_UserInfo_container { - padding-top: 16px; - padding-bottom: 0; - border-bottom: none; -} - -.mx_UserInfo_container_header { - display: flex; -} - -.mx_UserInfo_container_header_right { - position: relative; - margin-left: auto; -} - -.mx_UserInfo_newDmButton { - background-color: $roomheader-addroom-bg-color; - border-radius: 10px; // 16/2 + 2 padding - height: 16px; - flex: 0 0 16px; - - &::before { - background-color: $roomheader-addroom-fg-color; - mask: url('$(res)/img/icons-room-add.svg'); + .mx_UserInfo_cancel { + height: 16px; + width: 16px; + padding: 10px 0 10px 10px; + cursor: pointer; + mask-image: url('$(res)/img/minimise.svg'); mask-repeat: no-repeat; - mask-position: center; - content: ''; + mask-position: 16px center; + background-color: $rightpanel-button-color; position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; + } + + h2 { + font-size: 18px; + font-weight: 600; + margin: 18px 0 0 0; + } + + .mx_UserInfo_container { + padding: 0 16px 16px 16px; + border-bottom: 1px solid lightgray; + } + + .mx_UserInfo_memberDetailsContainer { + padding-bottom: 0; + } + + .mx_RoomTile_nameContainer { + width: 154px; + } + + .mx_RoomTile_badge { + display: none; + } + + .mx_RoomTile_name { + width: 160px; + } + + .mx_UserInfo_avatar { + margin: 24px 32px 0 32px; + cursor: pointer; + } + + .mx_UserInfo_avatar > div { + max-width: 30vh; + margin: 0 auto; + } + + .mx_UserInfo_avatar > div > div { + /* use padding-top instead of height to make this element square, + as the % in padding is a % of the width (including margin, + that's why we had to put the margin to center on a parent div), + and not a % of the parent height. */ + padding-top: 100%; + height: 0; + border-radius: 100%; + box-sizing: content-box; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + } + + .mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { + cursor: zoom-in; + } + + h3 { + text-transform: uppercase; + color: $notice-secondary-color; + font-weight: bold; + font-size: 12px; + margin: 4px 0; + } + + p { + margin: 5px 0; + } + + .mx_UserInfo_profile { + text-align: center; + + h2 { + font-size: 18px; + line-height: 25px; + flex: 1; + overflow-x: auto; + max-height: 50px; + display: flex; + justify-content: center; + align-items: center; + + .mx_E2EIcon { + margin: 5px; + } + } + + .mx_UserInfo_profileStatus { + margin-top: 12px; + } + } + + .mx_UserInfo_memberDetails .mx_UserInfo_profileField { + display: flex; + justify-content: center; + align-items: center; + + margin: 6px 0; + + .mx_IconButton, .mx_Spinner { + margin-left: 20px; + width: 16px; + height: 16px; + + &::before { + mask-size: 80%; + } + } + + .mx_UserInfo_roleDescription { + display: flex; + justify-content: center; + align-items: center; + // try to make it the same height as the dropdown + margin: 11px 0 12px 0; + + .mx_IconButton { + margin-left: 6px; + } + } + + .mx_Field { + margin: 0; + } + } + + .mx_UserInfo_field { + cursor: pointer; + color: $accent-color; + line-height: 16px; + margin: 8px 0; + + &.mx_UserInfo_destructive { + color: $warning-color; + } + } + + .mx_UserInfo_statusMessage { + font-size: 11px; + opacity: 0.5; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + } + + .mx_UserInfo_scrollContainer { + flex: 1 1 0; + padding-bottom: 16px; + } + + .mx_UserInfo_scrollContainer .mx_UserInfo_container { + padding-top: 16px; + padding-bottom: 0; + border-bottom: none; + + > :not(h3) { + margin-left: 8px; + } + } + + .mx_UserInfo_devices { + .mx_UserInfo_device { + display: flex; + margin: 8px 0; + + + &.mx_UserInfo_device_verified { + .mx_UserInfo_device_trusted { + color: $accent-color; + } + } + &.mx_UserInfo_device_unverified { + .mx_UserInfo_device_trusted { + color: $warning-color; + } + } + + .mx_UserInfo_device_name { + flex: 1; + margin-right: 5px; + word-break: break-word; + } + } + + // both for icon in expand button and device item + .mx_E2EIcon { + // don't squeeze + flex: 0 0 auto; + margin: 2px 5px 0 0; + width: 12px; + height: 12px; + } + + .mx_UserInfo_expand { + display: flex; + margin-top: 11px; + color: $accent-color; + } + } + + .mx_UserInfo_verify { + display: block; + background-color: $accent-color; + color: $accent-fg-color; + border-radius: 4px; + padding: 7px 1.5em; + text-align: center; + margin: 16px 0; } } diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 9ca6954af7..a3fe573ad0 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -153,40 +153,12 @@ $AppsDrawerBodyHeight: 273px; background-color: $accent-color; } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_reload { - mask-image: url('$(res)/img/feather-customised/widget/refresh.svg'); - mask-size: 100%; -} - .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_snapshot { - mask-image: url('$(res)/img/feather-customised/widget/camera.svg'); -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_edit { - mask-image: url('$(res)/img/feather-customised/widget/edit.svg'); -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_delete { - mask-image: url('$(res)/img/feather-customised/widget/bin.svg'); - background-color: $warning-color; -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_cancel { - mask-image: url('$(res)/img/feather-customised/widget/x-circle.svg'); -} - -/* delete ? */ -.mx_AppTileMenuBarWidget { - cursor: pointer; - width: 10px; - height: 10px; - padding: 1px; - transition-duration: 500ms; - border: 1px solid transparent; +.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu { + mask-image: url('$(res)/img/icon_context.svg'); } .mx_AppTileMenuBarWidgetDelete { @@ -294,49 +266,61 @@ form.mx_Custom_Widget_Form div { .mx_AppPermissionWarning { text-align: center; - background-color: $primary-bg-color; + background-color: $widget-menu-bar-bg-color; display: flex; height: 100%; flex-direction: column; justify-content: center; align-items: center; + font-size: 16px; } -.mx_AppPermissionWarningImage { - margin: 10px 0; +.mx_AppPermissionWarning_row { + margin-bottom: 12px; } -.mx_AppPermissionWarningImage img { - width: 100px; +.mx_AppPermissionWarning_smallText { + font-size: 12px; } -.mx_AppPermissionWarningText { - max-width: 90%; - margin: 10px auto 10px auto; - color: $primary-fg-color; +.mx_AppPermissionWarning_bolder { + font-weight: 600; } -.mx_AppPermissionWarningTextLabel { - font-weight: bold; - display: block; +.mx_AppPermissionWarning h4 { + margin: 0; + padding: 0; } -.mx_AppPermissionWarningTextURL { +.mx_AppPermissionWarning_helpIcon { + margin-top: 1px; + margin-right: 2px; + width: 10px; + height: 10px; display: inline-block; - max-width: 100%; - color: $accent-color; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; } -.mx_AppPermissionButton { - border: none; - padding: 5px 20px; - border-radius: 5px; - background-color: $button-bg-color; - color: $button-fg-color; - cursor: pointer; +.mx_AppPermissionWarning_helpIcon::before { + display: inline-block; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: 12px; + width: 12px; + height: 12px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/feather-customised/help-circle.svg'); +} + +.mx_AppPermissionWarning_tooltip { + @mixin mx_Tooltip_dark; + + ul { + list-style-position: inside; + padding-left: 2px; + margin-left: 0; + } } .mx_AppLoading { diff --git a/res/css/views/rooms/_E2EIcon.scss b/res/css/views/rooms/_E2EIcon.scss index 84a16611de..cb99aa63f1 100644 --- a/res/css/views/rooms/_E2EIcon.scss +++ b/res/css/views/rooms/_E2EIcon.scss @@ -15,19 +15,29 @@ limitations under the License. */ .mx_E2EIcon { - width: 25px; - height: 25px; - mask-repeat: no-repeat; - mask-position: center 0; + width: 16px; + height: 16px; margin: 0 9px; + position: relative; + display: block; } -.mx_E2EIcon_verified { - mask-image: url('$(res)/img/e2e/lock-verified.svg'); - background-color: $accent-color; +.mx_E2EIcon_verified::after, .mx_E2EIcon_warning::after { + content: ""; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-repeat: no-repeat; + background-size: contain; } -.mx_E2EIcon_warning { - mask-image: url('$(res)/img/e2e/lock-warning.svg'); - background-color: $warning-color; +.mx_E2EIcon_verified::after { + background-image: url('$(res)/img/e2e/verified.svg'); +} + +.mx_E2EIcon_warning::after { + background-image: url('$(res)/img/e2e/warning.svg'); } diff --git a/res/css/views/rooms/_MemberDeviceInfo.scss b/res/css/views/rooms/_MemberDeviceInfo.scss index 951d1945b1..e73e6c58f1 100644 --- a/res/css/views/rooms/_MemberDeviceInfo.scss +++ b/res/css/views/rooms/_MemberDeviceInfo.scss @@ -25,6 +25,7 @@ limitations under the License. width: 12px; height: 12px; mask-repeat: no-repeat; + mask-size: 100%; } .mx_MemberDeviceInfo_icon_blacklisted { mask-image: url('$(res)/img/e2e/blacklisted.svg'); diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index e9f33183f5..12e45a07c9 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -23,10 +23,6 @@ limitations under the License. padding-left: 84px; } -.mx_MessageComposer_wrapper.mx_MessageComposer_hasE2EIcon { - padding-left: 109px; -} - .mx_MessageComposer_replaced_wrapper { margin-left: auto; margin-right: auto; @@ -78,7 +74,8 @@ limitations under the License. .mx_MessageComposer_e2eIcon.mx_E2EIcon { position: absolute; left: 60px; - background-color: $composer-e2e-icon-color; + margin-right: 0; // Counteract the E2EIcon class + margin-left: 3px; // Counteract the E2EIcon class } .mx_MessageComposer_noperm_error { diff --git a/res/css/views/rooms/_MessageComposerFormatBar.scss b/res/css/views/rooms/_MessageComposerFormatBar.scss index 80909529ee..1b5a21bed0 100644 --- a/res/css/views/rooms/_MessageComposerFormatBar.scss +++ b/res/css/views/rooms/_MessageComposerFormatBar.scss @@ -40,6 +40,7 @@ limitations under the License. &:hover { border-color: $message-action-bar-hover-border-color; + z-index: 1; } &:first-child { diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 5da8ff76b9..f1e4456cc1 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -17,6 +17,10 @@ limitations under the License. .mx_RoomHeader { flex: 0 0 52px; border-bottom: 1px solid $primary-hairline-color; + + .mx_E2EIcon { + margin: 0 5px; + } } .mx_RoomHeader_wrapper { diff --git a/res/css/views/settings/_IntegrationsManager.scss b/res/css/views/settings/_IntegrationManager.scss similarity index 83% rename from res/css/views/settings/_IntegrationsManager.scss rename to res/css/views/settings/_IntegrationManager.scss index 8b51eb272e..81b01ab8de 100644 --- a/res/css/views/settings/_IntegrationsManager.scss +++ b/res/css/views/settings/_IntegrationManager.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_IntegrationsManager .mx_Dialog { +.mx_IntegrationManager .mx_Dialog { width: 60%; height: 70%; overflow: hidden; @@ -23,22 +23,22 @@ limitations under the License. max-height: initial; } -.mx_IntegrationsManager iframe { +.mx_IntegrationManager iframe { background-color: #fff; border: 0px; width: 100%; height: 100%; } -.mx_IntegrationsManager_loading h3 { +.mx_IntegrationManager_loading h3 { text-align: center; } -.mx_IntegrationsManager_error { +.mx_IntegrationManager_error { text-align: center; padding-top: 20px; } -.mx_IntegrationsManager_error h3 { +.mx_IntegrationManager_error h3 { color: $warning-color; } diff --git a/res/css/views/settings/_SetIntegrationManager.scss b/res/css/views/settings/_SetIntegrationManager.scss index 99537f9eb4..3e59ac73ac 100644 --- a/res/css/views/settings/_SetIntegrationManager.scss +++ b/res/css/views/settings/_SetIntegrationManager.scss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SetIntegrationManager .mx_Field_input { - @mixin mx_Settings_fullWidthField; -} - .mx_SetIntegrationManager { margin-top: 10px; margin-bottom: 10px; @@ -32,6 +28,10 @@ limitations under the License. padding-left: 5px; } -.mx_SetIntegrationManager_tooltip { - @mixin mx_Settings_tooltip; +.mx_SetIntegrationManager .mx_ToggleSwitch { + display: inline-block; + float: right; + top: 9px; + + @mixin mx_Settings_fullWidthField; } diff --git a/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss new file mode 100644 index 0000000000..2a3fd12f31 --- /dev/null +++ b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss @@ -0,0 +1,23 @@ +/* +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. +*/ + +.mx_MjolnirUserSettingsTab .mx_Field { + @mixin mx_Settings_fullWidthField; +} + +.mx_MjolnirUserSettingsTab_listItem { + margin-bottom: 2px; +} diff --git a/res/fonts/Nunito/Nunito-Bold.ttf b/res/fonts/Nunito/Nunito-Bold.ttf index c70de76bbd..c8fabf7d92 100644 Binary files a/res/fonts/Nunito/Nunito-Bold.ttf and b/res/fonts/Nunito/Nunito-Bold.ttf differ diff --git a/res/fonts/Nunito/Nunito-Regular.ttf b/res/fonts/Nunito/Nunito-Regular.ttf index 064e805431..86ce522f60 100644 Binary files a/res/fonts/Nunito/Nunito-Regular.ttf and b/res/fonts/Nunito/Nunito-Regular.ttf differ diff --git a/res/fonts/Nunito/Nunito-SemiBold.ttf b/res/fonts/Nunito/Nunito-SemiBold.ttf index a84b3b35a6..8bf953b59a 100644 Binary files a/res/fonts/Nunito/Nunito-SemiBold.ttf and b/res/fonts/Nunito/Nunito-SemiBold.ttf differ diff --git a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN44Y9vKUTo.ttf b/res/fonts/Nunito/XRXQ3I6Li01BKofIMN44Y9vKUTo.ttf deleted file mode 100644 index 4387fb67c4..0000000000 Binary files a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN44Y9vKUTo.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN5cYtvKUTo.ttf b/res/fonts/Nunito/XRXQ3I6Li01BKofIMN5cYtvKUTo.ttf deleted file mode 100644 index 68fb3ff5cb..0000000000 Binary files a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN5cYtvKUTo.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXV3I6Li01BKofINeaE.ttf b/res/fonts/Nunito/XRXV3I6Li01BKofINeaE.ttf deleted file mode 100644 index c40e599260..0000000000 Binary files a/res/fonts/Nunito/XRXV3I6Li01BKofINeaE.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXW3I6Li01BKofA6sKUYevN.ttf b/res/fonts/Nunito/XRXW3I6Li01BKofA6sKUYevN.ttf deleted file mode 100644 index 0c4fd17dfa..0000000000 Binary files a/res/fonts/Nunito/XRXW3I6Li01BKofA6sKUYevN.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXW3I6Li01BKofAjsOUYevN.ttf b/res/fonts/Nunito/XRXW3I6Li01BKofAjsOUYevN.ttf deleted file mode 100644 index 339d59ac00..0000000000 Binary files a/res/fonts/Nunito/XRXW3I6Li01BKofAjsOUYevN.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXX3I6Li01BKofIMNaDRss.ttf b/res/fonts/Nunito/XRXX3I6Li01BKofIMNaDRss.ttf deleted file mode 100644 index b5fcd891af..0000000000 Binary files a/res/fonts/Nunito/XRXX3I6Li01BKofIMNaDRss.ttf and /dev/null differ diff --git a/res/img/e2e/normal.svg b/res/img/e2e/normal.svg new file mode 100644 index 0000000000..5b848bc27f --- /dev/null +++ b/res/img/e2e/normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/e2e/verified.svg b/res/img/e2e/verified.svg index 459a552a40..464b443dcf 100644 --- a/res/img/e2e/verified.svg +++ b/res/img/e2e/verified.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/res/img/e2e/warning.svg b/res/img/e2e/warning.svg index 3d5fba550c..209ae0f71f 100644 --- a/res/img/e2e/warning.svg +++ b/res/img/e2e/warning.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/res/img/feather-customised/edit.svg b/res/img/feather-customised/edit.svg new file mode 100644 index 0000000000..f511aa1477 --- /dev/null +++ b/res/img/feather-customised/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/feather-customised/widget/bin.svg b/res/img/feather-customised/widget/bin.svg deleted file mode 100644 index 7616d8931b..0000000000 --- a/res/img/feather-customised/widget/bin.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/res/img/feather-customised/widget/camera.svg b/res/img/feather-customised/widget/camera.svg deleted file mode 100644 index 5502493068..0000000000 --- a/res/img/feather-customised/widget/camera.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/feather-customised/widget/edit.svg b/res/img/feather-customised/widget/edit.svg deleted file mode 100644 index 749e83f982..0000000000 --- a/res/img/feather-customised/widget/edit.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/feather-customised/widget/refresh.svg b/res/img/feather-customised/widget/refresh.svg deleted file mode 100644 index 0994bbdd52..0000000000 --- a/res/img/feather-customised/widget/refresh.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/feather-customised/widget/x-circle.svg b/res/img/feather-customised/widget/x-circle.svg deleted file mode 100644 index 951407b39c..0000000000 --- a/res/img/feather-customised/widget/x-circle.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index dcd7ce166e..0a3ef812b8 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -12,9 +12,9 @@ $monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emo // unified palette // try to use these colors when possible $accent-color: #03b381; -$accent-bg-color: rgba(115, 247, 91, 0.08); +$accent-bg-color: rgba(3, 179, 129, 0.16); $notice-primary-color: #ff4b55; -$notice-primary-bg-color: rgba(255, 75, 85, 0.08); +$notice-primary-bg-color: rgba(255, 75, 85, 0.16); $notice-secondary-color: #61708b; $header-panel-bg-color: #f3f8fd; diff --git a/src/BasePlatform.js b/src/BasePlatform.js index a97c14bf90..14e34a1f40 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -19,6 +19,7 @@ limitations under the License. */ import dis from './dispatcher'; +import BaseEventIndexManager from './indexing/BaseEventIndexManager'; /** * Base class for classes that provide platform-specific functionality @@ -151,4 +152,14 @@ export default class BasePlatform { async setMinimizeToTrayEnabled(enabled: boolean): void { throw new Error("Unimplemented"); } + + /** + * Get our platform specific EventIndexManager. + * + * @return {BaseEventIndexManager} The EventIndex manager for our platform, + * can be null if the platform doesn't support event indexing. + */ + getEventIndexingManager(): BaseEventIndexManager | null { + return null; + } } diff --git a/src/CallHandler.js b/src/CallHandler.js index bcdf7853fd..ecbf6c2c12 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -80,13 +80,26 @@ function play(audioId) { // which listens? const audio = document.getElementById(audioId); if (audio) { + const playAudio = async () => { + try { + // This still causes the chrome debugger to break on promise rejection if + // the promise is rejected, even though we're catching the exception. + await audio.play(); + } catch (e) { + // This is usually because the user hasn't interacted with the document, + // or chrome doesn't think so and is denying the request. Not sure what + // we can really do here... + // https://github.com/vector-im/riot-web/issues/7657 + console.log("Unable to play audio clip", e); + } + }; if (audioPromises[audioId]) { audioPromises[audioId] = audioPromises[audioId].then(()=>{ audio.load(); - return audio.play(); + return playAudio(); }); } else { - audioPromises[audioId] = audio.play(); + audioPromises[audioId] = playAudio(); } } } @@ -322,7 +335,7 @@ function _onAction(payload) { }); return; } else if (members.length === 2) { - console.log("Place %s call in %s", payload.type, payload.room_id); + console.info("Place %s call in %s", payload.type, payload.room_id); const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); placeCall(call); } else { // > 2 @@ -337,7 +350,7 @@ function _onAction(payload) { } break; case 'place_conference_call': - console.log("Place conference call in %s", payload.room_id); + console.info("Place conference call in %s", payload.room_id); _startCallApp(payload.room_id, payload.type); break; case 'incoming_call': @@ -382,7 +395,7 @@ function _onAction(payload) { } async function _startCallApp(roomId, type) { - // check for a working integrations manager. Technically we could put + // check for a working integration manager. Technically we could put // the state event in anyway, but the resulting widget would then not // work for us. Better that the user knows before everyone else in the // room sees it. @@ -495,6 +508,17 @@ async function _startCallApp(roomId, type) { // with the dispatcher once if (!global.mxCallHandler) { dis.register(_onAction); + // add empty handlers for media actions, otherwise the media keys + // end up causing the audio elements with our ring/ringback etc + // audio clips in to play. + if (navigator.mediaSession) { + navigator.mediaSession.setActionHandler('play', function() {}); + navigator.mediaSession.setActionHandler('pause', function() {}); + navigator.mediaSession.setActionHandler('seekbackward', function() {}); + navigator.mediaSession.setActionHandler('seekforward', function() {}); + navigator.mediaSession.setActionHandler('previoustrack', function() {}); + navigator.mediaSession.setActionHandler('nexttrack', function() {}); + } } const callHandler = { diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 2d58622db8..6908a6a18e 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -17,7 +17,6 @@ limitations under the License. 'use strict'; -import Promise from 'bluebird'; import extend from './extend'; import dis from './dispatcher'; import MatrixClientPeg from './MatrixClientPeg'; @@ -59,40 +58,38 @@ export class UploadCanceledError extends Error {} * and a thumbnail key. */ function createThumbnail(element, inputWidth, inputHeight, mimeType) { - const deferred = Promise.defer(); + return new Promise((resolve) => { + let targetWidth = inputWidth; + let targetHeight = inputHeight; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } - let targetWidth = inputWidth; - let targetHeight = inputHeight; - if (targetHeight > MAX_HEIGHT) { - targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); - targetHeight = MAX_HEIGHT; - } - if (targetWidth > MAX_WIDTH) { - targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); - targetWidth = MAX_WIDTH; - } - - const canvas = document.createElement("canvas"); - canvas.width = targetWidth; - canvas.height = targetHeight; - canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); - canvas.toBlob(function(thumbnail) { - deferred.resolve({ - info: { - thumbnail_info: { - w: targetWidth, - h: targetHeight, - mimetype: thumbnail.type, - size: thumbnail.size, + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); + canvas.toBlob(function(thumbnail) { + resolve({ + info: { + thumbnail_info: { + w: targetWidth, + h: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + }, + w: inputWidth, + h: inputHeight, }, - w: inputWidth, - h: inputHeight, - }, - thumbnail: thumbnail, - }); - }, mimeType); - - return deferred.promise; + thumbnail: thumbnail, + }); + }, mimeType); + }); } /** @@ -179,30 +176,29 @@ function infoForImageFile(matrixClient, roomId, imageFile) { * @return {Promise} A promise that resolves with the video image element. */ function loadVideoElement(videoFile) { - const deferred = Promise.defer(); + return new Promise((resolve, reject) => { + // Load the file into an html element + const video = document.createElement("video"); - // Load the file into an html element - const video = document.createElement("video"); + const reader = new FileReader(); - const reader = new FileReader(); - reader.onload = function(e) { - video.src = e.target.result; + reader.onload = function(e) { + video.src = e.target.result; - // Once ready, returns its size - // Wait until we have enough data to thumbnail the first frame. - video.onloadeddata = function() { - deferred.resolve(video); + // Once ready, returns its size + // Wait until we have enough data to thumbnail the first frame. + video.onloadeddata = function() { + resolve(video); + }; + video.onerror = function(e) { + reject(e); + }; }; - video.onerror = function(e) { - deferred.reject(e); + reader.onerror = function(e) { + reject(e); }; - }; - reader.onerror = function(e) { - deferred.reject(e); - }; - reader.readAsDataURL(videoFile); - - return deferred.promise; + reader.readAsDataURL(videoFile); + }); } /** @@ -236,16 +232,16 @@ function infoForVideoFile(matrixClient, roomId, videoFile) { * is read. */ function readFileAsArrayBuffer(file) { - const deferred = Promise.defer(); - const reader = new FileReader(); - reader.onload = function(e) { - deferred.resolve(e.target.result); - }; - reader.onerror = function(e) { - deferred.reject(e); - }; - reader.readAsArrayBuffer(file); - return deferred.promise; + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function(e) { + resolve(e.target.result); + }; + reader.onerror = function(e) { + reject(e); + }; + reader.readAsArrayBuffer(file); + }); } /** @@ -461,33 +457,34 @@ export default class ContentMessages { content.info.mimetype = file.type; } - const def = Promise.defer(); - if (file.type.indexOf('image/') == 0) { - content.msgtype = 'm.image'; - infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{ - extend(content.info, imageInfo); - def.resolve(); - }, (error)=>{ - console.error(error); + const prom = new Promise((resolve) => { + if (file.type.indexOf('image/') == 0) { + content.msgtype = 'm.image'; + infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{ + extend(content.info, imageInfo); + resolve(); + }, (error)=>{ + console.error(error); + content.msgtype = 'm.file'; + resolve(); + }); + } else if (file.type.indexOf('audio/') == 0) { + content.msgtype = 'm.audio'; + resolve(); + } else if (file.type.indexOf('video/') == 0) { + content.msgtype = 'm.video'; + infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{ + extend(content.info, videoInfo); + resolve(); + }, (error)=>{ + content.msgtype = 'm.file'; + resolve(); + }); + } else { content.msgtype = 'm.file'; - def.resolve(); - }); - } else if (file.type.indexOf('audio/') == 0) { - content.msgtype = 'm.audio'; - def.resolve(); - } else if (file.type.indexOf('video/') == 0) { - content.msgtype = 'm.video'; - infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{ - extend(content.info, videoInfo); - def.resolve(); - }, (error)=>{ - content.msgtype = 'm.file'; - def.resolve(); - }); - } else { - content.msgtype = 'm.file'; - def.resolve(); - } + resolve(); + } + }); const upload = { fileName: file.name || 'Attachment', @@ -509,7 +506,7 @@ export default class ContentMessages { dis.dispatch({action: 'upload_progress', upload: upload}); } - return def.promise.then(function() { + return prom.then(function() { // XXX: upload.promise must be the promise that // is returned by uploadFile as it has an abort() // method hacked onto it. diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index 7da37b6df1..793f5c9227 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -21,6 +21,7 @@ import MultiInviter from './utils/MultiInviter'; import { _t } from './languageHandler'; import MatrixClientPeg from './MatrixClientPeg'; import GroupStore from './stores/GroupStore'; +import {allSettled} from "./utils/promise"; export function showGroupInviteDialog(groupId) { return new Promise((resolve, reject) => { @@ -118,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) { function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const matrixClient = MatrixClientPeg.get(); const errorList = []; - return Promise.all(addrs.map((addr) => { + return allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroup(groupId, addr.address, addRoomsPublicly) .catch(() => { errorList.push(addr.address); }) @@ -138,7 +139,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { groups.push(groupId); return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, ''); } - }).reflect(); + }); })).then(() => { if (errorList.length === 0) { return; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 13f3abccb1..b81b563129 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -16,10 +16,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; import Matrix from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; +import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; import Analytics from './Analytics'; import Notifier from './Notifier'; @@ -36,6 +36,7 @@ import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; +import {Mjolnir} from "./mjolnir/Mjolnir"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -312,18 +313,14 @@ async function _restoreFromLocalStorage(opts) { function _handleLoadSessionFailure(e) { console.error("Unable to load session", e); - const def = Promise.defer(); const SessionRestoreErrorDialog = sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); - Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { + const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { error: e.message, - onFinished: (success) => { - def.resolve(success); - }, }); - return def.promise.then((success) => { + return modal.finished.then(([success]) => { if (success) { // user clicked continue. _clearStorage(); @@ -528,7 +525,7 @@ export function logout() { console.log("Failed to call logout API: token will not be invalidated"); onLoggedOut(); }, - ).done(); + ); } export function softLogout() { @@ -585,8 +582,14 @@ async function startMatrixClient(startSyncing=true) { IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); + // Start Mjolnir even though we haven't checked the feature flag yet. Starting + // the thing just wastes CPU cycles, but should result in no actual functionality + // being exposed to the user. + Mjolnir.sharedInstance().start(); + if (startSyncing) { await MatrixClientPeg.start(); + await EventIndexPeg.init(); } else { console.warn("Caller requested only auxiliary services be started"); await MatrixClientPeg.assign(); @@ -605,20 +608,20 @@ 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 function onLoggedOut() { +export async function onLoggedOut() { _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(); - _clearStorage().done(); + await _clearStorage(); } /** * @returns {Promise} promise which resolves once the stores have been cleared */ -function _clearStorage() { +async function _clearStorage() { Analytics.logout(); if (window.localStorage) { @@ -630,7 +633,9 @@ function _clearStorage() { // we'll never make any requests, so can pass a bogus HS URL baseUrl: "", }); - return cli.clearStores(); + + await EventIndexPeg.deleteEventIndex(); + await cli.clearStores(); } /** @@ -645,7 +650,9 @@ export function stopMatrixClient(unsetClient=true) { Presence.stop(); ActiveWidgetStore.stop(); IntegrationManagers.sharedInstance().stopWatching(); + Mjolnir.sharedInstance().stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); + EventIndexPeg.stop(); const cli = MatrixClientPeg.get(); if (cli) { cli.stopClient(); @@ -653,6 +660,7 @@ export function stopMatrixClient(unsetClient=true) { if (unsetClient) { MatrixClientPeg.unset(); + EventIndexPeg.unset(); } } } diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index bebb254afc..ef0130ec15 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -220,6 +220,16 @@ class MatrixClientPeg { identityServer: new IdentityAuthClient(), }; + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + // TODO: Cross-signing keys are temporarily in memory only. A + // separate task in the cross-signing project will build from here. + const keys = []; + opts.cryptoCallbacks = { + getCrossSigningKey: k => keys[k], + saveCrossSigningKeys: newKeys => Object.assign(keys, newKeys), + }; + } + this.matrixClient = createMatrixClient(opts); // we're going to add eventlisteners for each matrix event tile, so the diff --git a/src/Modal.js b/src/Modal.js index 26c9da8bbb..4fc9fdcb02 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -23,7 +23,7 @@ import Analytics from './Analytics'; import sdk from './index'; import dis from './dispatcher'; import { _t } from './languageHandler'; -import Promise from "bluebird"; +import {defer} from "./utils/promise"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; @@ -202,7 +202,7 @@ class ModalManager { } _getCloseFn(modal, props) { - const deferred = Promise.defer(); + const deferred = defer(); return [(...args) => { deferred.resolve(args); if (props && props.onFinished) props.onFinished.apply(null, args); diff --git a/src/Notifier.js b/src/Notifier.js index cca0ea2b89..dd691d8ca7 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -146,7 +146,7 @@ const Notifier = { } document.body.appendChild(audioElement); } - audioElement.play(); + await audioElement.play(); } catch (ex) { console.warn("Caught error when trying to fetch room notification sound:", ex); } @@ -198,7 +198,7 @@ const Notifier = { if (enable) { // Attempt to get permission from user - plaf.requestNotificationPermission().done((result) => { + plaf.requestNotificationPermission().then((result) => { if (result !== 'granted') { // The permission request was dismissed or denied // TODO: Support alternative branding in messaging diff --git a/src/Presence.js b/src/Presence.js index ca3db9b762..8ef988f171 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -96,7 +96,7 @@ class Presence { try { await MatrixClientPeg.get().setPresence(this.state); - console.log("Presence: %s", newState); + console.info("Presence: %s", newState); } catch (err) { console.error("Failed to set presence: %s", err); this.state = oldState; diff --git a/src/Resend.js b/src/Resend.js index 4eaee16d1b..51ec804c01 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -35,7 +35,7 @@ module.exports = { }, resend: function(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent(event, room).done(function(res) { + MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, diff --git a/src/Roles.js b/src/Roles.js index 10c4ceaf1e..7cc3c880d7 100644 --- a/src/Roles.js +++ b/src/Roles.js @@ -28,8 +28,8 @@ export function levelRoleMap(usersDefault) { export function textualPowerLevel(level, usersDefault) { const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); if (LEVEL_ROLE_MAP[level]) { - return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`); + return LEVEL_ROLE_MAP[level]; } else { - return level; + return _t("Custom (%(level)s)", {level}); } } diff --git a/src/RoomAliasCache.js b/src/RoomAliasCache.js new file mode 100644 index 0000000000..bb511ba4d7 --- /dev/null +++ b/src/RoomAliasCache.js @@ -0,0 +1,35 @@ +/* +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. +*/ + +/** + * This is meant to be a cache of room alias to room ID so that moving between + * rooms happens smoothly (for example using browser back / forward buttons). + * + * For the moment, it's in memory only and so only applies for the current + * session for simplicity, but could be extended further in the future. + * + * A similar thing could also be achieved via `pushState` with a state object, + * but keeping it separate like this seems easier in case we do want to extend. + */ +const aliasToIDMap = new Map(); + +export function storeRoomAliasInCache(alias, id) { + aliasToIDMap.set(alias, id); +} + +export function getCachedRoomIDForAlias(alias) { + return aliasToIDMap.get(alias); +} diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 64aab36128..b2b8689174 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -203,10 +203,13 @@ function _showAnyInviteErrors(addrs, room, inviter) { } if (errorList.length > 0) { + // React 16 doesn't let us use `errorList.join(
)` anymore, so this is our solution + const description =
{errorList.map(e =>
{e}
)}
; + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), - description: errorList.join(
), + description, }); } } @@ -225,4 +228,3 @@ function _getDirectMessageRooms(addr) { }); return rooms; } - diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 2d5e4b3136..5bef4afd25 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -17,7 +17,6 @@ limitations under the License. import MatrixClientPeg from './MatrixClientPeg'; import PushProcessor from 'matrix-js-sdk/lib/pushprocessor'; -import Promise from 'bluebird'; export const ALL_MESSAGES_LOUD = 'all_messages_loud'; export const ALL_MESSAGES = 'all_messages'; diff --git a/src/Rooms.js b/src/Rooms.js index c8f90ec39a..239e348b58 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -15,7 +15,6 @@ limitations under the License. */ import MatrixClientPeg from './MatrixClientPeg'; -import Promise from 'bluebird'; /** * Given a room object, return the alias we should use for it, diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 3623d47f8e..92f0ff6340 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -16,7 +16,6 @@ limitations under the License. */ import url from 'url'; -import Promise from 'bluebird'; import SettingsStore from "./settings/SettingsStore"; import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; const request = require('browser-request'); diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 910a6c4f13..c0ffc3022d 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -279,7 +279,7 @@ function inviteUser(event, roomId, userId) { } } - client.invite(roomId, userId).done(function() { + client.invite(roomId, userId).then(function() { sendResponse(event, { success: true, }); @@ -398,7 +398,7 @@ function setPlumbingState(event, roomId, status) { sendError(event, _t('You need to be logged in.')); return; } - client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).done(() => { + client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).then(() => { sendResponse(event, { success: true, }); @@ -414,7 +414,7 @@ function setBotOptions(event, roomId, userId) { sendError(event, _t('You need to be logged in.')); return; } - client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => { + client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).then(() => { sendResponse(event, { success: true, }); @@ -444,7 +444,7 @@ function setBotPower(event, roomId, userId, level) { }, ); - client.setPowerLevel(roomId, userId, level, powerEvent).done(() => { + client.setPowerLevel(roomId, userId, level, powerEvent).then(() => { sendResponse(event, { success: true, }); diff --git a/src/Searching.js b/src/Searching.js new file mode 100644 index 0000000000..f8976c92e4 --- /dev/null +++ b/src/Searching.js @@ -0,0 +1,138 @@ +/* +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 EventIndexPeg from "./indexing/EventIndexPeg"; +import MatrixClientPeg from "./MatrixClientPeg"; + +function serverSideSearch(term, roomId = undefined) { + let filter; + if (roomId !== undefined) { + // XXX: it's unintuitive that the filter for searching doesn't have + // the same shape as the v2 filter API :( + filter = { + rooms: [roomId], + }; + } + + const searchPromise = MatrixClientPeg.get().searchRoomEvents({ + filter, + term, + }); + + return searchPromise; +} + +async function combinedSearch(searchTerm) { + // Create two promises, one for the local search, one for the + // server-side search. + const serverSidePromise = serverSideSearch(searchTerm); + const localPromise = localSearch(searchTerm); + + // Wait for both promises to resolve. + await Promise.all([serverSidePromise, localPromise]); + + // Get both search results. + const localResult = await localPromise; + const serverSideResult = await serverSidePromise; + + // Combine the search results into one result. + const result = {}; + + // Our localResult and serverSideResult are both ordered by + // recency separately, when we combine them the order might not + // be the right one so we need to sort them. + const compare = (a, b) => { + const aEvent = a.context.getEvent().event; + const bEvent = b.context.getEvent().event; + + if (aEvent.origin_server_ts > + bEvent.origin_server_ts) return -1; + if (aEvent.origin_server_ts < + bEvent.origin_server_ts) return 1; + return 0; + }; + + result.count = localResult.count + serverSideResult.count; + result.results = localResult.results.concat( + serverSideResult.results).sort(compare); + result.highlights = localResult.highlights.concat( + serverSideResult.highlights); + + return result; +} + +async function localSearch(searchTerm, roomId = undefined) { + const searchArgs = { + search_term: searchTerm, + before_limit: 1, + after_limit: 1, + order_by_recency: true, + room_id: undefined, + }; + + if (roomId !== undefined) { + searchArgs.room_id = roomId; + } + + const eventIndex = EventIndexPeg.get(); + + const localResult = await eventIndex.search(searchArgs); + + const response = { + search_categories: { + room_events: localResult, + }, + }; + + const emptyResult = { + results: [], + highlights: [], + }; + + const result = MatrixClientPeg.get()._processRoomEventsSearch( + emptyResult, response); + + return result; +} + +function eventIndexSearch(term, roomId = undefined) { + let searchPromise; + + if (roomId !== undefined) { + if (MatrixClientPeg.get().isRoomEncrypted(roomId)) { + // The search is for a single encrypted room, use our local + // search method. + searchPromise = localSearch(term, roomId); + } else { + // The search is for a single non-encrypted room, use the + // server-side search. + searchPromise = serverSideSearch(term, roomId); + } + } else { + // Search across all rooms, combine a server side search and a + // local search. + searchPromise = combinedSearch(term); + } + + return searchPromise; +} + +export default function eventSearch(term, roomId = undefined) { + const eventIndex = EventIndexPeg.get(); + + if (eventIndex === null) return serverSideSearch(term, roomId); + else return eventIndexSearch(term, roomId); +} diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 1a491da54f..31e7ca4f39 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -28,7 +28,6 @@ import { linkifyAndSanitizeHtml } from './HtmlUtils'; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; import {textToHtmlRainbow} from "./utils/colour"; -import Promise from "bluebird"; import { getAddressType } from './UserAddress'; import { abbreviateUrl } from './utils/UrlUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; diff --git a/src/Terms.js b/src/Terms.js index 685a39709c..14a7ccb65e 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; import classNames from 'classnames'; import MatrixClientPeg from './MatrixClientPeg'; diff --git a/src/TextForEvent.js b/src/TextForEvent.js index e3c249df3f..cd0c5cfc5f 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -358,13 +358,25 @@ function textForCallHangupEvent(event) { function textForCallInviteEvent(event) { const senderName = event.sender ? event.sender.name : _t('Someone'); // FIXME: Find a better way to determine this from the event? - let callType = "voice"; + let isVoice = true; if (event.getContent().offer && event.getContent().offer.sdp && event.getContent().offer.sdp.indexOf('m=video') !== -1) { - callType = "video"; + isVoice = false; + } + const isSupported = MatrixClientPeg.get().supportsVoip(); + + // This ladder could be reduced down to a couple string variables, however other languages + // can have a hard time translating those strings. In an effort to make translations easier + // and more accurate, we break out the string-based variables to a couple booleans. + if (isVoice && isSupported) { + return _t("%(senderName)s placed a voice call.", {senderName}); + } else if (isVoice && !isSupported) { + return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName}); + } else if (!isVoice && isSupported) { + return _t("%(senderName)s placed a video call.", {senderName}); + } else if (!isVoice && !isSupported) { + return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName}); } - const supported = MatrixClientPeg.get().supportsVoip() ? "" : _t('(not supported by this browser)'); - return _t('%(senderName)s placed a %(callType)s call.', {senderName, callType}) + ' ' + supported; } function textForThreePidInviteEvent(event) { diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js index def4af56ae..00309d252c 100644 --- a/src/ToWidgetPostMessageApi.js +++ b/src/ToWidgetPostMessageApi.js @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from "bluebird"; - // const OUTBOUND_API_NAME = 'toWidget'; // Initiate requests using the "toWidget" postMessage API and handle responses diff --git a/src/VectorConferenceHandler.js b/src/VectorConferenceHandler.js index 37b3a7ddad..e0e333a371 100644 --- a/src/VectorConferenceHandler.js +++ b/src/VectorConferenceHandler.js @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; import {createNewMatrixCall, Room} from "matrix-js-sdk"; import CallHandler from './CallHandler'; import MatrixClientPeg from "./MatrixClientPeg"; diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index af2744950f..a26eb6033b 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -26,7 +26,7 @@ import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; import NotifProvider from './NotifProvider'; -import Promise from 'bluebird'; +import {timeout} from "../utils/promise"; export type SelectionRange = { beginning: boolean, // whether the selection is in the first block of the editor or not @@ -77,23 +77,16 @@ export default class Autocompleter { while the user is interacting with the list, which makes it difficult to predict whether an action will actually do what is intended */ - const completionsList = await Promise.all( - // Array of inspections of promises that might timeout. Instead of allowing a - // single timeout to reject the Promise.all, reflect each one and once they've all - // settled, filter for the fulfilled ones - this.providers.map(provider => - provider - .getCompletions(query, selection, force) - .timeout(PROVIDER_COMPLETION_TIMEOUT) - .reflect(), - ), - ); + const completionsList = await Promise.all(this.providers.map(provider => { + return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT); + })); + + // map then filter to maintain the index for the map-operation, for this.providers to line up + return completionsList.map((completions, i) => { + if (!completions || !completions.length) return; - return completionsList.filter( - (inspection) => inspection.isFulfilled(), - ).map((completionsState, i) => { return { - completions: completionsState.value(), + completions, provider: this.providers[i], /* the currently matched "command" the completer tried to complete @@ -102,6 +95,6 @@ export default class Autocompleter { */ command: this.providers[i].getCurrentCommand(query, selection, force), }; - }); + }).filter(Boolean); } } diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 4d8f47003c..a0aa36803f 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -19,7 +19,6 @@ limitations under the License. import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import Promise from 'bluebird'; import MatrixClientPeg from '../../MatrixClientPeg'; import sdk from '../../index'; import dis from '../../dispatcher'; @@ -38,6 +37,7 @@ import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; import {Group} from "matrix-js-sdk"; +import {allSettled, sleep} from "../../utils/promise"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -98,11 +98,10 @@ const CategoryRoomList = createReactClass({ onFinished: (success, addrs) => { if (!success) return; const errorList = []; - Promise.all(addrs.map((addr) => { + allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroupSummary(this.props.groupId, addr.address) - .catch(() => { errorList.push(addr.address); }) - .reflect(); + .catch(() => { errorList.push(addr.address); }); })).then(() => { if (errorList.length === 0) { return; @@ -275,11 +274,10 @@ const RoleUserList = createReactClass({ onFinished: (success, addrs) => { if (!success) return; const errorList = []; - Promise.all(addrs.map((addr) => { + allSettled(addrs.map((addr) => { return GroupStore .addUserToGroupSummary(addr.address) - .catch(() => { errorList.push(addr.address); }) - .reflect(); + .catch(() => { errorList.push(addr.address); }); })).then(() => { if (errorList.length === 0) { return; @@ -638,7 +636,7 @@ export default createReactClass({ title: _t('Error'), description: _t('Failed to upload image'), }); - }).done(); + }); }, _onJoinableChange: function(ev) { @@ -677,7 +675,7 @@ export default createReactClass({ this.setState({ avatarChanged: false, }); - }).done(); + }); }, _saveGroup: async function() { @@ -692,7 +690,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.acceptGroupInvite(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync @@ -711,7 +709,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync @@ -735,7 +733,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.joinGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync @@ -787,7 +785,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 5e06d124c4..e1b02f653b 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -121,7 +121,7 @@ export default createReactClass({ this.setState({ errorText: msg, }); - }).done(); + }); this._intervalId = null; if (this.props.poll) { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 889b0cdc8b..d071ba1d79 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -525,6 +525,7 @@ const LoggedInView = createReactClass({ const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); const GroupView = sdk.getComponent('structures.GroupView'); const MyGroups = sdk.getComponent('structures.MyGroups'); + const ToastContainer = sdk.getComponent('structures.ToastContainer'); const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); const CookieBar = sdk.getComponent('globals.CookieBar'); const NewVersionBar = sdk.getComponent('globals.NewVersionBar'); @@ -628,6 +629,7 @@ const LoggedInView = createReactClass({ return (
{ topBar } +
{ + MatrixClientPeg.get().leave(payload.room_id).then(() => { modal.close(); if (this.state.currentRoomId === payload.room_id) { dis.dispatch({action: 'view_next_room'}); @@ -627,6 +627,22 @@ export default createReactClass({ case 'view_invite': showRoomInviteDialog(payload.roomId); break; + case 'view_last_screen': + // This function does what we want, despite the name. The idea is that it shows + // the last room we were looking at or some reasonable default/guess. We don't + // have to worry about email invites or similar being re-triggered because the + // function will have cleared that state and not execute that path. + this._showScreenAfterLogin(); + break; + case 'toggle_my_groups': + // We just dispatch the page change rather than have to worry about + // what the logic is for each of these branches. + if (this.state.page_type === PageTypes.MyGroups) { + dis.dispatch({action: 'view_last_screen'}); + } else { + dis.dispatch({action: 'view_my_groups'}); + } + break; case 'notifier_enabled': { this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()}); } @@ -661,9 +677,6 @@ export default createReactClass({ }); break; } - case 'set_theme': - setTheme(payload.value); - break; case 'on_logging_in': // We are now logging in, so set the state to reflect that // NB. This does not touch 'ready' since if our dispatches @@ -861,12 +874,17 @@ export default createReactClass({ waitFor = this.firstSyncPromise.promise; } - waitFor.done(() => { + waitFor.then(() => { let presentedId = roomInfo.room_alias || roomInfo.room_id; const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); if (room) { const theAlias = Rooms.getDisplayAliasForRoom(room); - if (theAlias) presentedId = theAlias; + if (theAlias) { + presentedId = theAlias; + // Store display alias of the presented room in cache to speed future + // navigation. + storeRoomAliasInCache(theAlias, room.roomId); + } // Store this as the ID of the last room accessed. This is so that we can // persist which room is being stored across refreshes and browser quits. @@ -973,7 +991,7 @@ export default createReactClass({ const [shouldCreate, createOpts] = await modal.finished; if (shouldCreate) { - createRoom({createOpts}).done(); + createRoom({createOpts}); } }, @@ -1261,9 +1279,8 @@ export default createReactClass({ // since we're about to start the client and therefore about // to do the first sync this.firstSyncComplete = false; - this.firstSyncPromise = Promise.defer(); + this.firstSyncPromise = defer(); const cli = MatrixClientPeg.get(); - const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog'); // Allow the JS SDK to reap timeline events. This reduces the amount of // memory consumed as the JS SDK stores multiple distinct copies of room @@ -1308,7 +1325,7 @@ export default createReactClass({ if (state === "SYNCING" && prevState === "SYNCING") { return; } - console.log("MatrixClient sync state => %s", state); + console.info("MatrixClient sync state => %s", state); if (state !== "PREPARED") { return; } self.firstSyncComplete = true; @@ -1369,17 +1386,6 @@ export default createReactClass({ }, null, true); }); - cli.on("accountData", function(ev) { - if (ev.getType() === 'im.vector.web.settings') { - if (ev.getContent() && ev.getContent().theme) { - dis.dispatch({ - action: 'set_theme', - value: ev.getContent().theme, - }); - } - } - }); - const dft = new DecryptionFailureTracker((total, errorCode) => { Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total); }, (errorCode) => { @@ -1473,12 +1479,35 @@ export default createReactClass({ } }); - cli.on("crypto.verification.start", (verifier) => { - Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { - verifier, - }); - }); + if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { + cli.on("crypto.verification.request", request => { + let requestObserver; + if (request.event.getRoomId()) { + requestObserver = new KeyVerificationStateObserver( + request.event, MatrixClientPeg.get()); + } + if (!requestObserver || requestObserver.pending) { + dis.dispatch({ + action: "show_toast", + toast: { + key: request.event.getId(), + title: _t("Verification Request"), + icon: "verification", + props: {request, requestObserver}, + component: sdk.getComponent("toasts.VerificationRequestToast"), + }, + }); + } + }); + } else { + cli.on("crypto.verification.start", (verifier) => { + const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog"); + Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { + verifier, + }); + }); + } // Fire the tinter right on startup to ensure the default theme is applied // A later sync can/will correct the tint to be the right value for the user const colorScheme = SettingsStore.getValue("roomColor"); @@ -1749,7 +1778,7 @@ export default createReactClass({ return; } - cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => { + cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { dis.dispatch({action: 'message_sent'}); }, (err) => { dis.dispatch({action: 'message_send_failed'}); @@ -1761,10 +1790,12 @@ export default createReactClass({ const client = MatrixClientPeg.get(); const room = client && client.getRoom(this.state.currentRoomId); if (room) { - subtitle = `| ${ room.name } ${subtitle}`; + subtitle = `${this.subTitleStatus} | ${ room.name } ${subtitle}`; } + } else { + subtitle = `${this.subTitleStatus} ${subtitle}`; } - document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle} ${this.subTitleStatus}`; + document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`; }, updateStatusIndicator: function(state, prevState) { diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index cf2a5b1738..d1cc1b7caf 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,10 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* global Velocity */ - import React from 'react'; -import createReactClass from 'create-react-class'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -37,10 +35,8 @@ const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() /* (almost) stateless UI component which builds the event tiles in the room timeline. */ -module.exports = createReactClass({ - displayName: 'MessagePanel', - - propTypes: { +export default class MessagePanel extends React.Component { + static propTypes = { // true to give the component a 'display: none' style. hidden: PropTypes.bool, @@ -109,17 +105,16 @@ module.exports = createReactClass({ // whether to show reactions for an event showReactions: PropTypes.bool, - }, + }; - componentWillMount: function() { - // the event after which we put a visible unread marker on the last - // render cycle; null if readMarkerVisible was false or the RM was - // suppressed (eg because it was at the end of the timeline) - this.currentReadMarkerEventId = null; + constructor() { + super(); - // the event after which we are showing a disappearing read marker - // animation - this.currentGhostEventId = null; + this.state = { + // previous positions the read marker has been in, so we can + // display 'ghost' read markers that are animating away + ghostReadMarkers: [], + }; // opaque readreceipt info for each userId; used by ReadReceiptMarker // to manage its animations @@ -158,47 +153,57 @@ module.exports = createReactClass({ // displayed event in the current render cycle. this._readReceiptsByUserId = {}; - // Remember the read marker ghost node so we can do the cleanup that - // Velocity requires - this._readMarkerGhostNode = null; - // Cache hidden events setting on mount since Settings is expensive to // query, and we check this in a hot code path. this._showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline"); - this._isMounted = true; - }, - - componentWillUnmount: function() { this._isMounted = false; - }, + } + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.readMarkerVisible && this.props.readMarkerEventId !== prevProps.readMarkerEventId) { + const ghostReadMarkers = this.state.ghostReadMarkers; + ghostReadMarkers.push(prevProps.readMarkerEventId); + this.setState({ + ghostReadMarkers, + }); + } + } /* get the DOM node representing the given event */ - getNodeForEventId: function(eventId) { + getNodeForEventId(eventId) { if (!this.eventNodes) { return undefined; } return this.eventNodes[eventId]; - }, + } /* return true if the content is fully scrolled down right now; else false. */ - isAtBottom: function() { + isAtBottom() { return this.refs.scrollPanel && this.refs.scrollPanel.isAtBottom(); - }, + } /* get the current scroll state. See ScrollPanel.getScrollState for * details. * * returns null if we are not mounted. */ - getScrollState: function() { + getScrollState() { if (!this.refs.scrollPanel) { return null; } return this.refs.scrollPanel.getScrollState(); - }, + } // returns one of: // @@ -206,7 +211,7 @@ module.exports = createReactClass({ // -1: read marker is above the window // 0: read marker is within the window // +1: read marker is below the window - getReadMarkerPosition: function() { + getReadMarkerPosition() { const readMarker = this.refs.readMarkerNode; const messageWrapper = this.refs.scrollPanel; @@ -226,45 +231,45 @@ module.exports = createReactClass({ } else { return 1; } - }, + } /* jump to the top of the content. */ - scrollToTop: function() { + scrollToTop() { if (this.refs.scrollPanel) { this.refs.scrollPanel.scrollToTop(); } - }, + } /* jump to the bottom of the content. */ - scrollToBottom: function() { + scrollToBottom() { if (this.refs.scrollPanel) { this.refs.scrollPanel.scrollToBottom(); } - }, + } /** * Page up/down. * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative: function(mult) { + scrollRelative(mult) { if (this.refs.scrollPanel) { this.refs.scrollPanel.scrollRelative(mult); } - }, + } /** * Scroll up/down in response to a scroll key * * @param {KeyboardEvent} ev: the keyboard event to handle */ - handleScrollKey: function(ev) { + handleScrollKey(ev) { if (this.refs.scrollPanel) { this.refs.scrollPanel.handleScrollKey(ev); } - }, + } /* jump to the given event id. * @@ -276,33 +281,33 @@ module.exports = createReactClass({ * node (specifically, the bottom of it) will be positioned. If omitted, it * defaults to 0. */ - scrollToEvent: function(eventId, pixelOffset, offsetBase) { + scrollToEvent(eventId, pixelOffset, offsetBase) { if (this.refs.scrollPanel) { this.refs.scrollPanel.scrollToToken(eventId, pixelOffset, offsetBase); } - }, + } - scrollToEventIfNeeded: function(eventId) { + scrollToEventIfNeeded(eventId) { const node = this.eventNodes[eventId]; if (node) { node.scrollIntoView({block: "nearest", behavior: "instant"}); } - }, + } /* check the scroll state and send out pagination requests if necessary. */ - checkFillState: function() { + checkFillState() { if (this.refs.scrollPanel) { this.refs.scrollPanel.checkFillState(); } - }, + } - _isUnmounting: function() { + _isUnmounting() { return !this._isMounted; - }, + } // TODO: Implement granular (per-room) hide options - _shouldShowEvent: function(mxEv) { + _shouldShowEvent(mxEv) { if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { return false; // ignored = no show (only happens if the ignore happens after an event was received) } @@ -320,16 +325,87 @@ module.exports = createReactClass({ if (this.props.highlightedEventId === mxEv.getId()) return true; return !shouldHideEvent(mxEv); - }, + } - _getEventTiles: function() { + _readMarkerForEvent(eventId, isLastEvent) { + const visible = !isLastEvent && this.props.readMarkerVisible; + + if (this.props.readMarkerEventId === eventId) { + let hr; + // if the read marker comes at the end of the timeline (except + // for local echoes, which are excluded from RMs, because they + // don't have useful event ids), we don't want to show it, but + // we still want to create the
  • for it so that the + // algorithms which depend on its position on the screen aren't + // confused. + if (visible) { + hr =
    ; + } + + return ( +
  • + { hr } +
  • + ); + } else if (this.state.ghostReadMarkers.includes(eventId)) { + // We render 'ghost' read markers in the DOM while they + // transition away. This allows the actual read marker + // to be in the right place straight away without having + // to wait for the transition to finish. + // There are probably much simpler ways to do this transition, + // possibly using react-transition-group which handles keeping + // elements in the DOM whilst they transition out, although our + // case is a little more complex because only some of the items + // transition (ie. the read markers do but the event tiles do not) + // and TransitionGroup requires that all its children are Transitions. + const hr =
    ; + + // give it a key which depends on the event id. That will ensure that + // we get a new DOM node (restarting the animation) when the ghost + // moves to a different event. + return ( +
  • + { hr } +
  • + ); + } + + return null; + } + + _collectGhostReadMarker = (node) => { + if (node) { + // now the element has appeared, change the style which will trigger the CSS transition + requestAnimationFrame(() => { + node.style.width = '10%'; + node.style.opacity = '0'; + }); + } + }; + + _onGhostTransitionEnd = (ev) => { + // we can now clean up the ghost element + const finishedEventId = ev.target.dataset.eventid; + this.setState({ + ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId), + }); + }; + + _getEventTiles() { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); this.eventNodes = {}; - let visible = false; let i; // first figure out which is the last event in the list which we're @@ -364,16 +440,6 @@ module.exports = createReactClass({ let prevEvent = null; // the last event we showed - // assume there is no read marker until proven otherwise - let readMarkerVisible = false; - - // if the readmarker has moved, cancel any active ghost. - if (this.currentReadMarkerEventId && this.props.readMarkerEventId && - this.props.readMarkerVisible && - this.currentReadMarkerEventId !== this.props.readMarkerEventId) { - this.currentGhostEventId = null; - } - this._readReceiptsByEvent = {}; if (this.props.showReadReceipts) { this._readReceiptsByEvent = this._getReadReceiptsByShownEvent(); @@ -398,7 +464,7 @@ module.exports = createReactClass({ return false; }; if (mxEv.getType() === "m.room.create") { - let readMarkerInSummary = false; + let summaryReadMarker = null; const ts1 = mxEv.getTs(); if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) { @@ -407,8 +473,12 @@ module.exports = createReactClass({ } // If RM event is the first in the summary, append the RM after the summary - if (mxEv.getId() === this.props.readMarkerEventId) { - readMarkerInSummary = true; + summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId()); + + // If this m.room.create event should be shown (room upgrade) then show it before the summary + if (this._shouldShowEvent(mxEv)) { + // pass in the mxEv as prevEvent as well so no extra DateSeparator is rendered + ret.push(...this._getTilesForEvent(mxEv, mxEv, false)); } const summarisedEvents = []; // Don't add m.room.create here as we don't want it inside the summary @@ -418,9 +488,7 @@ module.exports = createReactClass({ // Ignore redacted/hidden member events if (!this._shouldShowEvent(collapsedMxEv)) { // If this hidden event is the RM and in or at end of a summary put RM after the summary. - if (collapsedMxEv.getId() === this.props.readMarkerEventId) { - readMarkerInSummary = true; - } + summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); continue; } @@ -429,9 +497,7 @@ module.exports = createReactClass({ } // If RM event is in the summary, mark it as such and the RM will be appended after the summary. - if (collapsedMxEv.getId() === this.props.readMarkerEventId) { - readMarkerInSummary = true; - } + summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); summarisedEvents.push(collapsedMxEv); } @@ -459,8 +525,8 @@ module.exports = createReactClass({ { eventTiles } ); - if (readMarkerInSummary) { - ret.push(this._getReadMarkerTile(visible)); + if (summaryReadMarker) { + ret.push(summaryReadMarker); } prevEvent = mxEv; @@ -471,7 +537,7 @@ module.exports = createReactClass({ // Wrap consecutive member events in a ListSummary, ignore if redacted if (isMembershipChange(mxEv) && wantTile) { - let readMarkerInMels = false; + let summaryReadMarker = null; const ts1 = mxEv.getTs(); // Ensure that the key of the MemberEventListSummary does not change with new // member events. This will prevent it from being re-created unnecessarily, and @@ -489,9 +555,7 @@ module.exports = createReactClass({ } // If RM event is the first in the MELS, append the RM after MELS - if (mxEv.getId() === this.props.readMarkerEventId) { - readMarkerInMels = true; - } + summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId()); const summarisedEvents = [mxEv]; for (;i + 1 < this.props.events.length; i++) { @@ -500,9 +564,7 @@ module.exports = createReactClass({ // Ignore redacted/hidden member events if (!this._shouldShowEvent(collapsedMxEv)) { // If this hidden event is the RM and in or at end of a MELS put RM after MELS. - if (collapsedMxEv.getId() === this.props.readMarkerEventId) { - readMarkerInMels = true; - } + summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); continue; } @@ -512,9 +574,7 @@ module.exports = createReactClass({ } // If RM event is in MELS mark it as such and the RM will be appended after MELS. - if (collapsedMxEv.getId() === this.props.readMarkerEventId) { - readMarkerInMels = true; - } + summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); summarisedEvents.push(collapsedMxEv); } @@ -545,8 +605,8 @@ module.exports = createReactClass({ { eventTiles } ); - if (readMarkerInMels) { - ret.push(this._getReadMarkerTile(visible)); + if (summaryReadMarker) { + ret.push(summaryReadMarker); } prevEvent = mxEv; @@ -561,44 +621,14 @@ module.exports = createReactClass({ prevEvent = mxEv; } - let isVisibleReadMarker = false; - - if (eventId === this.props.readMarkerEventId) { - visible = this.props.readMarkerVisible; - - // if the read marker comes at the end of the timeline (except - // for local echoes, which are excluded from RMs, because they - // don't have useful event ids), we don't want to show it, but - // we still want to create the
  • for it so that the - // algorithms which depend on its position on the screen aren't - // confused. - if (i >= lastShownNonLocalEchoIndex) { - visible = false; - } - ret.push(this._getReadMarkerTile(visible)); - readMarkerVisible = visible; - isVisibleReadMarker = visible; - } - - // XXX: there should be no need for a ghost tile - we should just use a - // a dispatch (user_activity_end) to start the RM animation. - if (eventId === this.currentGhostEventId) { - // if we're showing an animation, continue to show it. - ret.push(this._getReadMarkerGhostTile()); - } else if (!isVisibleReadMarker && - eventId === this.currentReadMarkerEventId) { - // there is currently a read-up-to marker at this point, but no - // more. Show an animation of it disappearing. - ret.push(this._getReadMarkerGhostTile()); - this.currentGhostEventId = eventId; - } + const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); + if (readMarker) ret.push(readMarker); } - this.currentReadMarkerEventId = readMarkerVisible ? this.props.readMarkerEventId : null; return ret; - }, + } - _getTilesForEvent: function(prevEvent, mxEv, last) { + _getTilesForEvent(prevEvent, mxEv, last) { const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); const ret = []; @@ -691,20 +721,20 @@ module.exports = createReactClass({ ); return ret; - }, + } - _wantsDateSeparator: function(prevEvent, nextEventDate) { + _wantsDateSeparator(prevEvent, nextEventDate) { if (prevEvent == null) { // first event in the panel: depends if we could back-paginate from // here. return !this.props.suppressFirstDateSeparator; } return wantsDateSeparator(prevEvent.getDate(), nextEventDate); - }, + } // Get a list of read receipts that should be shown next to this event // Receipts are objects which have a 'userId', 'roomMember' and 'ts'. - _getReadReceiptsForEvent: function(event) { + _getReadReceiptsForEvent(event) { const myUserId = MatrixClientPeg.get().credentials.userId; // get list of read receipts, sorted most recent first @@ -728,12 +758,12 @@ module.exports = createReactClass({ }); }); return receipts; - }, + } // Get an object that maps from event ID to a list of read receipts that // should be shown next to that event. If a hidden event has read receipts, // they are folded into the receipts of the last shown event. - _getReadReceiptsByShownEvent: function() { + _getReadReceiptsByShownEvent() { const receiptsByEvent = {}; const receiptsByUserId = {}; @@ -786,78 +816,31 @@ module.exports = createReactClass({ } return receiptsByEvent; - }, + } - _getReadMarkerTile: function(visible) { - let hr; - if (visible) { - hr =
    ; - } - - return ( -
  • - { hr } -
  • - ); - }, - - _startAnimation: function(ghostNode) { - if (this._readMarkerGhostNode) { - Velocity.Utilities.removeData(this._readMarkerGhostNode); - } - this._readMarkerGhostNode = ghostNode; - - if (ghostNode) { - // eslint-disable-next-line new-cap - Velocity(ghostNode, {opacity: '0', width: '10%'}, - {duration: 400, easing: 'easeInSine', - delay: 1000}); - } - }, - - _getReadMarkerGhostTile: function() { - const hr =
    ; - - // give it a key which depends on the event id. That will ensure that - // we get a new DOM node (restarting the animation) when the ghost - // moves to a different event. - return ( -
  • - { hr } -
  • - ); - }, - - _collectEventNode: function(eventId, node) { + _collectEventNode = (eventId, node) => { this.eventNodes[eventId] = node; - }, + } // once dynamic content in the events load, make the scrollPanel check the // scroll offsets. - _onHeightChanged: function() { + _onHeightChanged = () => { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { scrollPanel.checkScroll(); } - }, + }; - _onTypingShown: function() { + _onTypingShown = () => { const scrollPanel = this.refs.scrollPanel; // this will make the timeline grow, so checkScroll scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { scrollPanel.preventShrinking(); } - }, + }; - _onTypingHidden: function() { + _onTypingHidden = () => { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { // as hiding the typing notifications doesn't @@ -868,9 +851,9 @@ module.exports = createReactClass({ // reveal added padding to balance the notifs disappearing. scrollPanel.checkScroll(); } - }, + }; - updateTimelineMinHeight: function() { + updateTimelineMinHeight() { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { @@ -885,16 +868,16 @@ module.exports = createReactClass({ scrollPanel.preventShrinking(); } } - }, + } - onTimelineReset: function() { + onTimelineReset() { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { scrollPanel.clearPreventShrinking(); } - }, + } - render: function() { + render() { const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile"); const Spinner = sdk.getComponent("elements.Spinner"); @@ -941,5 +924,5 @@ module.exports = createReactClass({ { bottomSpinner } ); - }, -}); + } +} diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 2de15a5444..63ae14ba09 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -47,7 +47,7 @@ export default createReactClass({ }, _fetch: function() { - this.context.matrixClient.getJoinedGroups().done((result) => { + this.context.matrixClient.getJoinedGroups().then((result) => { this.setState({groups: result.groups, error: null}); }, (err) => { if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') { diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 48d272f6c9..895f6ae57e 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -185,7 +185,7 @@ export default class RightPanel extends React.Component { } else if (this.state.phase === RightPanel.Phase.GroupRoomList) { panel = ; } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_user_info_panel")) { + if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { const onClose = () => { dis.dispatch({ action: "view_user", @@ -204,7 +204,7 @@ export default class RightPanel extends React.Component { } else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) { panel = ; } else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_user_info_panel")) { + if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { const onClose = () => { dis.dispatch({ action: "view_user", diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 84f402e484..efca8d12a8 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -27,7 +27,6 @@ const dis = require('../../dispatcher'); import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; import PropTypes from 'prop-types'; -import Promise from 'bluebird'; import { _t } from '../../languageHandler'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; @@ -89,7 +88,7 @@ module.exports = createReactClass({ this.setState({protocolsLoading: false}); return; } - MatrixClientPeg.get().getThirdpartyProtocols().done((response) => { + MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { this.protocols = response; this.setState({protocolsLoading: false}); }, (err) => { @@ -135,7 +134,7 @@ module.exports = createReactClass({ publicRooms: [], loading: true, }); - this.getMoreRooms().done(); + this.getMoreRooms(); }, getMoreRooms: function() { @@ -246,7 +245,7 @@ module.exports = createReactClass({ if (!alias) return; step = _t('delete the alias.'); return MatrixClientPeg.get().deleteAlias(alias); - }).done(() => { + }).then(() => { modal.close(); this.refreshRoomList(); }, (err) => { @@ -348,7 +347,7 @@ module.exports = createReactClass({ }); return; } - MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => { + MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).then((resp) => { if (resp.length > 0 && resp[0].alias) { this.showRoomAlias(resp[0].alias, true); } else { diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 21dd06767c..b0aa4cb59b 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -289,7 +289,7 @@ module.exports = createReactClass({ } return
    - +
    { title } @@ -306,7 +306,7 @@ module.exports = createReactClass({ if (this._shouldShowConnectionError()) { return (
    - /!\ + /!\
    { _t('Connectivity to the server has been lost.') } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 4de573479d..5cc1e2b765 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -27,7 +27,6 @@ import React from 'react'; import createReactClass from 'create-react-class'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; -import Promise from 'bluebird'; import classNames from 'classnames'; import {Room} from "matrix-js-sdk"; import { _t } from '../../languageHandler'; @@ -43,6 +42,7 @@ import Tinter from '../../Tinter'; import rate_limited_func from '../../ratelimitedfunc'; import ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; +import eventSearch from '../../Searching'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; @@ -357,7 +357,7 @@ module.exports = createReactClass({ if (this.props.autoJoin) { this.onJoinButtonClicked(); } else if (!room && shouldPeek) { - console.log("Attempting to peek into room %s", roomId); + console.info("Attempting to peek into room %s", roomId); this.setState({ peekLoading: true, isPeeking: true, // this will change to false if peeking fails @@ -1101,7 +1101,7 @@ module.exports = createReactClass({ } ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) - .done(undefined, (error) => { + .then(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this return; @@ -1129,23 +1129,12 @@ module.exports = createReactClass({ // todo: should cancel any previous search requests. this.searchId = new Date().getTime(); - let filter; - if (scope === "Room") { - filter = { - // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :( - rooms: [ - this.state.room.roomId, - ], - }; - } + let roomId; + if (scope === "Room") roomId = this.state.room.roomId; debuglog("sending search request"); - - const searchPromise = MatrixClientPeg.get().searchRoomEvents({ - filter: filter, - term: term, - }); - this._handleSearchResult(searchPromise).done(); + const searchPromise = eventSearch(term, roomId); + this._handleSearchResult(searchPromise); }, _handleSearchResult: function(searchPromise) { @@ -1316,7 +1305,7 @@ module.exports = createReactClass({ }, onForgetClick: function() { - MatrixClientPeg.get().forget(this.state.room.roomId).done(function() { + MatrixClientPeg.get().forget(this.state.room.roomId).then(function() { dis.dispatch({ action: 'view_next_room' }); }, function(err) { const errCode = err.errcode || _t("unknown error code"); @@ -1333,7 +1322,7 @@ module.exports = createReactClass({ this.setState({ rejecting: true, }); - MatrixClientPeg.get().leave(this.state.roomId).done(function() { + MatrixClientPeg.get().leave(this.state.roomId).then(function() { dis.dispatch({ action: 'view_next_room' }); self.setState({ rejecting: false, @@ -1907,7 +1896,7 @@ module.exports = createReactClass({ highlightedEventId = this.state.initialEventId; } - // console.log("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); + // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); const messagePanel = ( { + this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { if (this.unmounted) { return; } const { events, liveEvents } = this._getEvents(); @@ -1064,8 +1063,6 @@ const TimelinePanel = createReactClass({ }); }; - let prom = this._timelineWindow.load(eventId, INITIAL_SIZE); - // if we already have the event in question, TimelineWindow.load // returns a resolved promise. // @@ -1074,9 +1071,14 @@ const TimelinePanel = createReactClass({ // quite slow. So we detect that situation and shortcut straight to // calling _reloadEvents and updating the state. - if (prom.isFulfilled()) { + const timeline = this.props.timelineSet.getTimelineForEvent(eventId); + if (timeline) { + // This is a hot-path optimization by skipping a promise tick + // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline + this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time onLoaded(); } else { + const prom = this._timelineWindow.load(eventId, INITIAL_SIZE); this.setState({ events: [], liveEvents: [], @@ -1084,11 +1086,8 @@ const TimelinePanel = createReactClass({ canForwardPaginate: false, timelineLoading: true, }); - - prom = prom.then(onLoaded, onError); + prom.then(onLoaded, onError); } - - prom.done(); }, // handle the completion of a timeline load or localEchoUpdate, by diff --git a/src/components/structures/ToastContainer.js b/src/components/structures/ToastContainer.js new file mode 100644 index 0000000000..a8dca35747 --- /dev/null +++ b/src/components/structures/ToastContainer.js @@ -0,0 +1,84 @@ +/* +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 * as React from "react"; +import dis from "../../dispatcher"; +import { _t } from '../../languageHandler'; +import classNames from "classnames"; + +export default class ToastContainer extends React.Component { + constructor() { + super(); + this.state = {toasts: []}; + } + + componentDidMount() { + this._dispatcherRef = dis.register(this.onAction); + } + + componentWillUnmount() { + dis.unregister(this._dispatcherRef); + } + + onAction = (payload) => { + if (payload.action === "show_toast") { + this._addToast(payload.toast); + } + }; + + _addToast(toast) { + this.setState({toasts: this.state.toasts.concat(toast)}); + } + + dismissTopToast = () => { + const [, ...remaining] = this.state.toasts; + this.setState({toasts: remaining}); + }; + + render() { + const totalCount = this.state.toasts.length; + const isStacked = totalCount > 1; + let toast; + if (totalCount !== 0) { + const topToast = this.state.toasts[0]; + const {title, icon, key, component, props} = topToast; + const toastClasses = classNames("mx_Toast_toast", { + "mx_Toast_hasIcon": icon, + [`mx_Toast_icon_${icon}`]: icon, + }); + const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null; + + const toastProps = Object.assign({}, props, { + dismiss: this.dismissTopToast, + key, + }); + toast = (
    +

    {title}{countIndicator}

    +
    {React.createElement(component, toastProps)}
    +
    ); + } + + const containerClasses = classNames("mx_ToastContainer", { + "mx_ToastContainer_stacked": isStacked, + }); + + return ( +
    + {toast} +
    + ); + } +} diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 46a5fa7bd7..6f68293caa 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -105,7 +105,7 @@ module.exports = createReactClass({ phase: PHASE_SENDING_EMAIL, }); this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); - this.reset.resetPassword(email, password).done(() => { + this.reset.resetPassword(email, password).then(() => { this.setState({ phase: PHASE_EMAIL_SENT, }); diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index ad77ed49a5..b2e9d3e7cd 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -253,7 +253,7 @@ module.exports = createReactClass({ this.setState({ busy: false, }); - }).done(); + }); }, onUsernameChanged: function(username) { @@ -378,15 +378,30 @@ module.exports = createReactClass({ // Do a quick liveliness check on the URLs try { - await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); - this.setState({serverIsAlive: true, errorText: ""}); + const { warning } = + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); + if (warning) { + this.setState({ + ...AutoDiscoveryUtils.authComponentStateForError(warning), + errorText: "", + }); + } else { + this.setState({ + serverIsAlive: true, + errorText: "", + }); + } } catch (e) { this.setState({ busy: false, ...AutoDiscoveryUtils.authComponentStateForError(e), }); if (this.state.serverErrorIsFatal) { - return; // Server is dead - do not continue. + // Server is dead: show server details prompt instead + this.setState({ + phase: PHASE_SERVER_DETAILS, + }); + return; } } @@ -424,7 +439,7 @@ module.exports = createReactClass({ this.setState({ busy: false, }); - }).done(); + }); }, _isSupportedFlow: function(flow) { diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js index 66075c80f7..760163585d 100644 --- a/src/components/structures/auth/PostRegistration.js +++ b/src/components/structures/auth/PostRegistration.js @@ -43,7 +43,7 @@ module.exports = createReactClass({ const cli = MatrixClientPeg.get(); this.setState({busy: true}); const self = this; - cli.getProfileInfo(cli.credentials.userId).done(function(result) { + cli.getProfileInfo(cli.credentials.userId).then(function(result) { self.setState({ avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url), busy: false, diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 6321028457..3578d745f5 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -18,7 +18,6 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; -import Promise from 'bluebird'; import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; @@ -371,7 +370,7 @@ module.exports = createReactClass({ if (pushers[i].kind === 'email') { const emailPusher = pushers[i]; emailPusher.data = { brand: this.props.brand }; - matrixClient.setPusher(emailPusher).done(() => { + matrixClient.setPusher(emailPusher).then(() => { console.log("Set email branding to " + this.props.brand); }, (error) => { console.error("Couldn't set email branding: " + error); diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index 2fdfedadcf..23fef6c54f 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -89,7 +89,7 @@ module.exports = createReactClass({ + "authentication"); } - console.log("Rendering to %s", divId); + console.info("Rendering to %s", divId); this._captchaWidgetId = global.grecaptcha.render(divId, { sitekey: publicKey, callback: this.props.onCaptchaResponse, diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index d19ce95b33..cc3f9f96c4 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -441,7 +441,7 @@ export const MsisdnAuthEntry = createReactClass({ this.props.fail(e); }).finally(() => { this.setState({requestingToken: false}); - }).done(); + }); }, /* diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js index e00e3dcdbb..f770bb0d60 100644 --- a/src/components/views/context_menus/RoomTileContextMenu.js +++ b/src/components/views/context_menus/RoomTileContextMenu.js @@ -17,7 +17,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; @@ -32,6 +31,7 @@ import * as RoomNotifs from '../../../RoomNotifs'; import Modal from '../../../Modal'; import RoomListActions from '../../../actions/RoomListActions'; import RoomViewStore from '../../../stores/RoomViewStore'; +import {sleep} from "../../../utils/promise"; import {MenuItem, MenuItemCheckbox, MenuItemRadio} from "../../structures/ContextualMenu"; const RoomTagOption = ({active, onClick, src, srcSet, label}) => { @@ -92,7 +92,7 @@ module.exports = createReactClass({ _toggleTag: function(tagNameOn, tagNameOff) { if (!MatrixClientPeg.get().isGuest()) { - Promise.delay(500).then(() => { + sleep(500).then(() => { dis.dispatch(RoomListActions.tagRoom( MatrixClientPeg.get(), this.props.room, @@ -149,7 +149,7 @@ module.exports = createReactClass({ Rooms.guessAndSetDMRoom( this.props.room, newIsDirectMessage, - ).delay(500).finally(() => { + ).then(sleep(500)).finally(() => { // Close the context menu if (this.props.onFinished) { this.props.onFinished(); @@ -190,7 +190,7 @@ module.exports = createReactClass({ _onClickForget: function() { // FIXME: duplicated with RoomSettings (and dead code in RoomView) - MatrixClientPeg.get().forget(this.props.room.roomId).done(() => { + MatrixClientPeg.get().forget(this.props.room.roomId).then(() => { // Switch to another room view if we're currently viewing the // historical room if (RoomViewStore.getRoomId() === this.props.room.roomId) { @@ -220,10 +220,10 @@ module.exports = createReactClass({ this.setState({ roomNotifState: newState, }); - RoomNotifs.setRoomNotifsState(roomId, newState).done(() => { + RoomNotifs.setRoomNotifsState(roomId, newState).then(() => { // delay slightly so that the user can see their state change // before closing the menu - return Promise.delay(500).then(() => { + return sleep(500).then(() => { if (this._unmounted) return; // Close the context menu if (this.props.onFinished) { diff --git a/src/components/views/context_menus/WidgetContextMenu.js b/src/components/views/context_menus/WidgetContextMenu.js new file mode 100644 index 0000000000..43e7e172cc --- /dev/null +++ b/src/components/views/context_menus/WidgetContextMenu.js @@ -0,0 +1,134 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import {_t} from '../../../languageHandler'; + +export default class WidgetContextMenu extends React.Component { + static propTypes = { + onFinished: PropTypes.func, + + // Callback for when the revoke button is clicked. Required. + onRevokeClicked: PropTypes.func.isRequired, + + // Callback for when the snapshot button is clicked. Button not shown + // without a callback. + onSnapshotClicked: PropTypes.func, + + // Callback for when the reload button is clicked. Button not shown + // without a callback. + onReloadClicked: PropTypes.func, + + // Callback for when the edit button is clicked. Button not shown + // without a callback. + onEditClicked: PropTypes.func, + + // Callback for when the delete button is clicked. Button not shown + // without a callback. + onDeleteClicked: PropTypes.func, + }; + + proxyClick(fn) { + fn(); + if (this.props.onFinished) this.props.onFinished(); + } + + // XXX: It's annoying that our context menus require us to hit onFinished() to close :( + + onEditClicked = () => { + this.proxyClick(this.props.onEditClicked); + }; + + onReloadClicked = () => { + this.proxyClick(this.props.onReloadClicked); + }; + + onSnapshotClicked = () => { + this.proxyClick(this.props.onSnapshotClicked); + }; + + onDeleteClicked = () => { + this.proxyClick(this.props.onDeleteClicked); + }; + + onRevokeClicked = () => { + this.proxyClick(this.props.onRevokeClicked); + }; + + render() { + const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton"); + + const options = []; + + if (this.props.onEditClicked) { + options.push( + + {_t("Edit")} + , + ); + } + + if (this.props.onReloadClicked) { + options.push( + + {_t("Reload")} + , + ); + } + + if (this.props.onSnapshotClicked) { + options.push( + + {_t("Take picture")} + , + ); + } + + if (this.props.onDeleteClicked) { + options.push( + + {_t("Remove for everyone")} + , + ); + } + + // Push this last so it appears last. It's always present. + options.push( + + {_t("Remove for me")} + , + ); + + // Put separators between the options + if (options.length > 1) { + const length = options.length; + for (let i = 0; i < length - 1; i++) { + const sep =
    ; + + // Insert backwards so the insertions don't affect our math on where to place them. + // We also use our cached length to avoid worrying about options.length changing + options.splice(length - 1 - i, 0, sep); + } + } + + return
    {options}
    ; + } +} diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index fb779fa96f..a40495893d 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -25,13 +25,13 @@ import { _t, _td } from '../../../languageHandler'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import dis from '../../../dispatcher'; -import Promise from 'bluebird'; import { addressTypes, getAddressType } from '../../../UserAddress.js'; import GroupStore from '../../../stores/GroupStore'; import * as Email from '../../../email'; import IdentityAuthClient from '../../../IdentityAuthClient'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils'; import { abbreviateUrl } from '../../../utils/UrlUtils'; +import {sleep} from "../../../utils/promise"; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -266,7 +266,7 @@ module.exports = createReactClass({ this.setState({ searchError: err.errcode ? err.message : _t('Something went wrong!'), }); - }).done(() => { + }).then(() => { this.setState({ busy: false, }); @@ -379,7 +379,7 @@ module.exports = createReactClass({ // Do a local search immediately this._doLocalSearch(query); } - }).done(() => { + }).then(() => { this.setState({ busy: false, }); @@ -533,7 +533,7 @@ module.exports = createReactClass({ }; // wait a bit to let the user finish typing - await Promise.delay(500); + await sleep(500); if (cancelled) return null; try { diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 11f4c21366..3430a12e71 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -93,7 +93,7 @@ export default createReactClass({ this.setState({createError: e}); }).finally(() => { this.setState({creating: false}); - }).done(); + }); }, _onCancel: function() { diff --git a/src/components/views/dialogs/IntegrationsDisabledDialog.js b/src/components/views/dialogs/IntegrationsDisabledDialog.js new file mode 100644 index 0000000000..3ab1123f8b --- /dev/null +++ b/src/components/views/dialogs/IntegrationsDisabledDialog.js @@ -0,0 +1,57 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {_t} from "../../../languageHandler"; +import sdk from "../../../index"; +import dis from '../../../dispatcher'; + +export default class IntegrationsDisabledDialog extends React.Component { + static propTypes = { + onFinished: PropTypes.func.isRequired, + }; + + _onAcknowledgeClick = () => { + this.props.onFinished(); + }; + + _onOpenSettingsClick = () => { + this.props.onFinished(); + dis.dispatch({action: "view_user_settings"}); + }; + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + return ( + +
    +

    {_t("Enable 'Manage Integrations' in Settings to do this.")}

    +
    + +
    + ); + } +} diff --git a/src/components/views/dialogs/IntegrationsImpossibleDialog.js b/src/components/views/dialogs/IntegrationsImpossibleDialog.js new file mode 100644 index 0000000000..9927f627f1 --- /dev/null +++ b/src/components/views/dialogs/IntegrationsImpossibleDialog.js @@ -0,0 +1,55 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {_t} from "../../../languageHandler"; +import sdk from "../../../index"; + +export default class IntegrationsImpossibleDialog extends React.Component { + static propTypes = { + onFinished: PropTypes.func.isRequired, + }; + + _onAcknowledgeClick = () => { + this.props.onFinished(); + }; + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + return ( + +
    +

    + {_t( + "Your Riot doesn't allow you to use an Integration Manager to do this. " + + "Please contact an admin.", + )} +

    +
    + +
    + ); + } +} diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index a10c25a0fb..01e3479bb1 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -78,7 +78,7 @@ export default createReactClass({ true, ); } - }).done(); + }); }, componentWillUnmount: function() { diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.js b/src/components/views/dialogs/MessageEditHistoryDialog.js index 6014cb941c..b5e4daa1c1 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.js +++ b/src/components/views/dialogs/MessageEditHistoryDialog.js @@ -116,7 +116,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { nodes.push(( { + this._addThreepid.addEmailAddress(emailAddress).then(() => { Modal.createTrackedDialog('Verification Pending', '', QuestionDialog, { title: _t("Verification Pending"), description: _t( @@ -96,7 +96,7 @@ export default createReactClass({ }, verifyEmailAddress: function() { - this._addThreepid.checkEmailLinkClicked().done(() => { + this._addThreepid.checkEmailLinkClicked().then(() => { this.props.onFinished(true); }, (err) => { this.setState({emailBusy: false}); diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index 3bc6f5597e..598d0ce354 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -15,7 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; diff --git a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js index 5ef7aef9ab..e86a46fb36 100644 --- a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js +++ b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js @@ -82,10 +82,10 @@ export default class TabbedIntegrationManagerDialog extends React.Component { client.setTermsInteractionCallback((policyInfo, agreedUrls) => { // To avoid visual glitching of two modals stacking briefly, we customise the - // terms dialog sizing when it will appear for the integrations manager so that + // terms dialog sizing when it will appear for the integration manager so that // it gets the same basic size as the IM's own modal. return dialogTermsInteractionCallback( - policyInfo, agreedUrls, 'mx_TermsDialog_forIntegrationsManager', + policyInfo, agreedUrls, 'mx_TermsDialog_forIntegrationManager', ); }); @@ -139,7 +139,7 @@ export default class TabbedIntegrationManagerDialog extends React.Component { } _renderTab() { - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + const IntegrationManager = sdk.getComponent("views.settings.IntegrationManager"); let uiUrl = null; if (this.state.currentScalarClient) { uiUrl = this.state.currentScalarClient.getScalarInterfaceUrlForRoom( @@ -148,7 +148,7 @@ export default class TabbedIntegrationManagerDialog extends React.Component { this.props.integrationId, ); } - return {_t("Identity Server")}
    ({host})
    ; case Matrix.SERVICE_TYPES.IM: - return
    {_t("Integrations Manager")}
    ({host})
    ; + return
    {_t("Integration Manager")}
    ({host})
    ; } } diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js index fb9045f05a..d3ab2b8722 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.js @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -29,12 +30,34 @@ import HelpUserSettingsTab from "../settings/tabs/user/HelpUserSettingsTab"; import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab"; import sdk from "../../../index"; import SdkConfig from "../../../SdkConfig"; +import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab"; export default class UserSettingsDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, }; + constructor() { + super(); + + this.state = { + mjolnirEnabled: SettingsStore.isFeatureEnabled("feature_mjolnir"), + }; + } + + componentDidMount(): void { + this._mjolnirWatcher = SettingsStore.watchSetting("feature_mjolnir", null, this._mjolnirChanged.bind(this)); + } + + componentWillUnmount(): void { + SettingsStore.unwatchSetting(this._mjolnirWatcher); + } + + _mjolnirChanged(settingName, roomId, atLevel, newValue) { + // We can cheat because we know what levels a feature is tracked at, and how it is tracked + this.setState({mjolnirEnabled: newValue}); + } + _getTabs() { const tabs = []; @@ -75,6 +98,13 @@ export default class UserSettingsDialog extends React.Component { , )); } + if (this.state.mjolnirEnabled) { + tabs.push(new Tab( + _td("Ignored users"), + "mx_UserSettingsDialog_mjolnirIcon", + , + )); + } tabs.push(new Tab( _td("Help & About"), "mx_UserSettingsDialog_helpIcon", diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.js index 1e019c0287..8dc58643bd 100644 --- a/src/components/views/elements/AppPermission.js +++ b/src/components/views/elements/AppPermission.js @@ -19,79 +19,126 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import url from 'url'; +import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import WidgetUtils from "../../../utils/WidgetUtils"; +import MatrixClientPeg from "../../../MatrixClientPeg"; export default class AppPermission extends React.Component { + static propTypes = { + url: PropTypes.string.isRequired, + creatorUserId: PropTypes.string.isRequired, + roomId: PropTypes.string.isRequired, + onPermissionGranted: PropTypes.func.isRequired, + isRoomEncrypted: PropTypes.bool, + }; + + static defaultProps = { + onPermissionGranted: () => {}, + }; + constructor(props) { super(props); - const curlBase = this.getCurlBase(); - this.state = { curlBase: curlBase}; + // The first step is to pick apart the widget so we can render information about it + const urlInfo = this.parseWidgetUrl(); + + // The second step is to find the user's profile so we can show it on the prompt + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + let roomMember; + if (room) roomMember = room.getMember(this.props.creatorUserId); + + // Set all this into the initial state + this.state = { + ...urlInfo, + roomMember, + }; } - // Return string representation of content URL without query parameters - getCurlBase() { - const wurl = url.parse(this.props.url); - let curl; - let curlString; + parseWidgetUrl() { + const widgetUrl = url.parse(this.props.url); + const params = new URLSearchParams(widgetUrl.search); - const searchParams = new URLSearchParams(wurl.search); - - if (WidgetUtils.isScalarUrl(wurl) && searchParams && searchParams.get('url')) { - curl = url.parse(searchParams.get('url')); - if (curl) { - curl.search = curl.query = ""; - curlString = curl.format(); - } + // HACK: We're relying on the query params when we should be relying on the widget's `data`. + // This is a workaround for Scalar. + if (WidgetUtils.isScalarUrl(widgetUrl) && params && params.get('url')) { + const unwrappedUrl = url.parse(params.get('url')); + return { + widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname, + isWrapped: true, + }; + } else { + return { + widgetDomain: widgetUrl.host || widgetUrl.hostname, + isWrapped: false, + }; } - if (!curl && wurl) { - wurl.search = wurl.query = ""; - curlString = wurl.format(); - } - return curlString; } render() { - let e2eWarningText; - if (this.props.isRoomEncrypted) { - e2eWarningText = - { _t('NOTE: Apps are not end-to-end encrypted') }; - } - const cookieWarning = - - { _t('Warning: This widget might use cookies.') } - ; + const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton"); + const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); + const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); + const TextWithTooltip = sdk.getComponent("views.elements.TextWithTooltip"); + + const displayName = this.state.roomMember ? this.state.roomMember.name : this.props.creatorUserId; + const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId; + + const avatar = this.state.roomMember + ? + : ; + + const warningTooltipText = ( +
    + {_t("Any of the following data may be shared:")} +
      +
    • {_t("Your display name")}
    • +
    • {_t("Your avatar URL")}
    • +
    • {_t("Your user ID")}
    • +
    • {_t("Your theme")}
    • +
    • {_t("Riot URL")}
    • +
    • {_t("Room ID")}
    • +
    • {_t("Widget ID")}
    • +
    +
    + ); + const warningTooltip = ( + + + + ); + + // Due to i18n limitations, we can't dedupe the code for variables in these two messages. + const warning = this.state.isWrapped + ? _t("Using this widget may share data with %(widgetDomain)s & your Integration Manager.", + {widgetDomain: this.state.widgetDomain}, {helpIcon: () => warningTooltip}) + : _t("Using this widget may share data with %(widgetDomain)s.", + {widgetDomain: this.state.widgetDomain}, {helpIcon: () => warningTooltip}); + + const encryptionWarning = this.props.isRoomEncrypted ? _t("Widgets do not use message encryption.") : null; + return (
    -
    - {_t('Warning!')} +
    + {_t("Widget added by")}
    -
    - {_t('Do you want to load widget from URL:')} - {this.state.curlBase} - { e2eWarningText } - { cookieWarning } +
    + {avatar} +

    {displayName}

    +
    {userId}
    +
    +
    + {warning} +
    +
    + {_t("This widget may use cookies.")} {encryptionWarning} +
    +
    + + {_t("Continue")} +
    -
    ); } } - -AppPermission.propTypes = { - isRoomEncrypted: PropTypes.bool, - url: PropTypes.string.isRequired, - onPermissionGranted: PropTypes.func.isRequired, -}; -AppPermission.defaultProps = { - isRoomEncrypted: false, - onPermissionGranted: function() {}, -}; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 260b63dfd4..9a29843d3b 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -34,7 +34,9 @@ import dis from '../../../dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; -import SettingsStore from "../../../settings/SettingsStore"; +import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; +import {createMenu} from "../../structures/ContextualMenu"; +import PersistedElement from "./PersistedElement"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; @@ -52,7 +54,7 @@ export default class AppTile extends React.Component { this._onLoaded = this._onLoaded.bind(this); this._onEditClick = this._onEditClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this); - this._onCancelClick = this._onCancelClick.bind(this); + this._onRevokeClicked = this._onRevokeClicked.bind(this); this._onSnapshotClick = this._onSnapshotClick.bind(this); this.onClickMenuBar = this.onClickMenuBar.bind(this); this._onMinimiseClick = this._onMinimiseClick.bind(this); @@ -69,8 +71,11 @@ export default class AppTile extends React.Component { * @return {Object} Updated component state to be set with setState */ _getNewState(newProps) { - const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_'); - const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); + // This is a function to make the impact of calling SettingsStore slightly less + const hasPermissionToLoad = () => { + const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId); + return !!currentlyAllowedWidgets[newProps.eventId]; + }; const PersistedElement = sdk.getComponent("elements.PersistedElement"); return { @@ -78,10 +83,9 @@ export default class AppTile extends React.Component { // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), widgetUrl: this._addWurlParams(newProps.url), - widgetPermissionId: widgetPermissionId, // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user - hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId, + hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), error: null, deleting: false, widgetPageTitle: newProps.widgetPageTitle, @@ -205,7 +209,7 @@ export default class AppTile extends React.Component { if (!this._scalarClient) { this._scalarClient = defaultManager.getScalarClient(); } - this._scalarClient.getScalarToken().done((token) => { + this._scalarClient.getScalarToken().then((token) => { // Append scalar_token as a query param if not already present this._scalarClient.scalarToken = token; const u = url.parse(this._addWurlParams(this.props.url)); @@ -244,7 +248,8 @@ export default class AppTile extends React.Component { this.setScalarToken(); } } else if (nextProps.show && !this.props.show) { - if (this.props.waitForIframeLoad) { + // We assume that persisted widgets are loaded and don't need a spinner. + if (this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey)) { this.setState({ loading: true, }); @@ -269,7 +274,7 @@ export default class AppTile extends React.Component { return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); } - _onEditClick(e) { + _onEditClick() { console.log("Edit widget ID ", this.props.id); if (this.props.onEditClick) { this.props.onEditClick(); @@ -291,7 +296,7 @@ export default class AppTile extends React.Component { } } - _onSnapshotClick(e) { + _onSnapshotClick() { console.warn("Requesting widget snapshot"); ActiveWidgetStore.getWidgetMessaging(this.props.id).getScreenshot() .catch((err) => { @@ -358,13 +363,9 @@ export default class AppTile extends React.Component { } } - _onCancelClick() { - if (this.props.onDeleteClick) { - this.props.onDeleteClick(); - } else { - console.log("Revoke widget permissions - %s", this.props.id); - this._revokeWidgetPermission(); - } + _onRevokeClicked() { + console.info("Revoke widget permissions - %s", this.props.id); + this._revokeWidgetPermission(); } /** @@ -446,24 +447,38 @@ export default class AppTile extends React.Component { }); } - /* TODO -- Store permission in account data so that it is persisted across multiple devices */ _grantWidgetPermission() { - console.warn('Granting permission to load widget - ', this.state.widgetUrl); - localStorage.setItem(this.state.widgetPermissionId, true); - this.setState({hasPermissionToLoad: true}); - // Now that we have permission, fetch the IM token - this.setScalarToken(); + const roomId = this.props.room.roomId; + console.info("Granting permission for widget to load: " + this.props.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + current[this.props.eventId] = true; + SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { + this.setState({hasPermissionToLoad: true}); + + // Fetch a token for the integration manager, now that we're allowed to + this.setScalarToken(); + }).catch(err => { + console.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); } _revokeWidgetPermission() { - console.warn('Revoking permission to load widget - ', this.state.widgetUrl); - localStorage.removeItem(this.state.widgetPermissionId); - this.setState({hasPermissionToLoad: false}); + const roomId = this.props.room.roomId; + console.info("Revoking permission for widget to load: " + this.props.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + current[this.props.eventId] = false; + SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { + this.setState({hasPermissionToLoad: false}); - // Force the widget to be non-persistent - ActiveWidgetStore.destroyPersistentWidget(this.props.id); - const PersistedElement = sdk.getComponent("elements.PersistedElement"); - PersistedElement.destroyElement(this._persistKey); + // Force the widget to be non-persistent (able to be deleted/forgotten) + ActiveWidgetStore.destroyPersistentWidget(this.props.id); + const PersistedElement = sdk.getComponent("elements.PersistedElement"); + PersistedElement.destroyElement(this._persistKey); + }).catch(err => { + console.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); } formatAppTileName() { @@ -528,18 +543,59 @@ export default class AppTile extends React.Component { } } - _onPopoutWidgetClick(e) { + _onPopoutWidgetClick() { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getSafeUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), { target: '_blank', href: this._getSafeUrl(), rel: 'noopener'}).click(); } - _onReloadWidgetClick(e) { + _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions this.refs.appFrame.src = this.refs.appFrame.src; } + _getMenuOptions(ev) { + // TODO: This block of code gets copy/pasted a lot. We should make that happen less. + const menuOptions = {}; + const buttonRect = ev.target.getBoundingClientRect(); + // The window X and Y offsets are to adjust position when zoomed in to page + const buttonLeft = buttonRect.left + window.pageXOffset; + const buttonTop = buttonRect.top + window.pageYOffset; + // Align the right edge of the menu to the left edge of the button + menuOptions.right = window.innerWidth - buttonLeft; + // Align the menu vertically on whichever side of the button has more + // space available. + if (buttonTop < window.innerHeight / 2) { + menuOptions.top = buttonTop; + } else { + menuOptions.bottom = window.innerHeight - buttonTop; + } + return menuOptions; + } + + _onContextMenuClick = (ev) => { + const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); + const menuOptions = { + ...this._getMenuOptions(ev), + + // A revoke handler is always required + onRevokeClicked: this._onRevokeClicked, + }; + + const canUserModify = this._canUserModify(); + const showEditButton = Boolean(this._scalarClient && canUserModify); + const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify; + const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show; + + if (showEditButton) menuOptions.onEditClicked = this._onEditClick; + if (showDeleteButton) menuOptions.onDeleteClicked = this._onDeleteClick; + if (showPictureSnapshotButton) menuOptions.onSnapshotClicked = this._onSnapshotClick; + if (this.props.showReload) menuOptions.onReloadClicked = this._onReloadWidgetClick; + + createMenu(WidgetContextMenu, menuOptions); + }; + render() { let appTileBody; @@ -549,7 +605,7 @@ export default class AppTile extends React.Component { } // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin - // because that would allow the iframe to prgramatically remove the sandbox attribute, but + // because that would allow the iframe to programmatically remove the sandbox attribute, but // this would only be for content hosted on the same origin as the riot client: anything // hosted on the same origin as the client will get the same access as if you clicked // a link to it. @@ -569,12 +625,14 @@ export default class AppTile extends React.Component {
    ); if (!this.state.hasPermissionToLoad) { - const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); + const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); appTileBody = (
    @@ -596,12 +654,7 @@ export default class AppTile extends React.Component { appTileBody = (
    { this.state.loading && loadingElement } - { /* - The "is" attribute in the following iframe tag is needed in order to enable rendering of the - "allow" attribute, which is unknown to react 15. - */ }