diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 4f9826391a..456c97d580 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -10,7 +10,7 @@ on: jobs: end-to-end: runs-on: ubuntu-latest - env: + env: PR_NUMBER: ${{github.event.number}} container: vectorim/element-web-ci-e2etests-env:latest steps: diff --git a/.github/workflows/notify-element-web.yml b/.github/workflows/notify-element-web.yml new file mode 100644 index 0000000000..ef463784f3 --- /dev/null +++ b/.github/workflows/notify-element-web.yml @@ -0,0 +1,15 @@ +name: Notify element-web +on: + push: + branches: [develop] +jobs: + notify-element-web: + runs-on: ubuntu-latest + environment: develop + steps: + - name: Notify element-web repo that a new SDK build is on develop + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.ELEMENT_WEB_NOTIFY_TOKEN }} + repository: vector-im/element-web + event-type: element-web-notify diff --git a/.stylelintrc.js b/.stylelintrc.js index c044b19a63..0bdea3cccd 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -11,7 +11,8 @@ module.exports = { "length-zero-no-unit": null, "rule-empty-line-before": null, "color-hex-length": null, - "max-empty-lines": null, + "max-empty-lines": 1, + "no-eol-whitespace": true, "number-no-trailing-zeros": null, "number-leading-zero": null, "selector-list-comma-newline-after": null, diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fa6c3c80..42e62d8271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,60 @@ +Changes in [3.34.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.34.0) (2021-11-08) +===================================================================================================== + +## ✨ Features + * Improve the look of tooltips ([\#7049](https://github.com/matrix-org/matrix-react-sdk/pull/7049)). Contributed by @SimonBrandner. + * Improve the look of the spinner ([\#6083](https://github.com/matrix-org/matrix-react-sdk/pull/6083)). Contributed by @SimonBrandner. + * Polls: Creation form & start event ([\#7001](https://github.com/matrix-org/matrix-react-sdk/pull/7001)). + * Show a gray shield when encrypted by deleted session ([\#6119](https://github.com/matrix-org/matrix-react-sdk/pull/6119)). Contributed by @SimonBrandner. + * Silence some widgets for better screen reader presentation. ([\#7057](https://github.com/matrix-org/matrix-react-sdk/pull/7057)). Contributed by @ndarilek. + * Make message separator more accessible. ([\#7056](https://github.com/matrix-org/matrix-react-sdk/pull/7056)). Contributed by @ndarilek. + * Give each room directory entry the `listitem` role to correspond with the containing `list`. ([\#7035](https://github.com/matrix-org/matrix-react-sdk/pull/7035)). Contributed by @ndarilek. + * Implement RequiresClient capability for widgets ([\#7005](https://github.com/matrix-org/matrix-react-sdk/pull/7005)). Fixes vector-im/element-web#15744 and vector-im/element-web#15744. + * Respect the system high contrast setting when using system theme ([\#7043](https://github.com/matrix-org/matrix-react-sdk/pull/7043)). + * Remove redundant duplicate mimetype field which doesn't conform to spec ([\#7045](https://github.com/matrix-org/matrix-react-sdk/pull/7045)). Fixes vector-im/element-web#17145 and vector-im/element-web#17145. + * Make join button on space hierarchy action in the background ([\#7041](https://github.com/matrix-org/matrix-react-sdk/pull/7041)). Fixes vector-im/element-web#17388 and vector-im/element-web#17388. + * Add a high contrast theme (a variant of the light theme) ([\#7036](https://github.com/matrix-org/matrix-react-sdk/pull/7036)). + * Improve timeline message for restricted join rule changes ([\#6984](https://github.com/matrix-org/matrix-react-sdk/pull/6984)). Fixes vector-im/element-web#18980 and vector-im/element-web#18980. + * Improve the appearance of the font size slider ([\#7038](https://github.com/matrix-org/matrix-react-sdk/pull/7038)). + * Improve RovingTabIndex & Room List filtering performance ([\#6987](https://github.com/matrix-org/matrix-react-sdk/pull/6987)). Fixes vector-im/element-web#17864 and vector-im/element-web#17864. + * Remove outdated Spaces restricted rooms warning ([\#6927](https://github.com/matrix-org/matrix-react-sdk/pull/6927)). + * Make /msg param optional for more flexibility ([\#7028](https://github.com/matrix-org/matrix-react-sdk/pull/7028)). Fixes vector-im/element-web#19481 and vector-im/element-web#19481. + * Add decoration to space hierarchy for tiles which have already been j… ([\#6969](https://github.com/matrix-org/matrix-react-sdk/pull/6969)). Fixes vector-im/element-web#18755 and vector-im/element-web#18755. + * Add insert link button to the format bar ([\#5879](https://github.com/matrix-org/matrix-react-sdk/pull/5879)). Contributed by @SimonBrandner. + * Improve visibility of font size chooser ([\#6988](https://github.com/matrix-org/matrix-react-sdk/pull/6988)). + * Soften border-radius on selected/hovered messages ([\#6525](https://github.com/matrix-org/matrix-react-sdk/pull/6525)). Fixes vector-im/element-web#18108. Contributed by @SimonBrandner. + * Add a developer mode flag and use it for accessing space timelines ([\#6994](https://github.com/matrix-org/matrix-react-sdk/pull/6994)). Fixes vector-im/element-web#19416 and vector-im/element-web#19416. + * Position toggle switch more clearly ([\#6914](https://github.com/matrix-org/matrix-react-sdk/pull/6914)). Contributed by @CicadaCinema. + * Validate email address in forgot password dialog ([\#6983](https://github.com/matrix-org/matrix-react-sdk/pull/6983)). Fixes vector-im/element-web#9978 and vector-im/element-web#9978. Contributed by @psrpinto. + * Handle and i18n M_THREEPID_IN_USE during registration ([\#6986](https://github.com/matrix-org/matrix-react-sdk/pull/6986)). Fixes vector-im/element-web#13767 and vector-im/element-web#13767. + * For space invite previews, use room summary API to get the right member count ([\#6982](https://github.com/matrix-org/matrix-react-sdk/pull/6982)). Fixes vector-im/element-web#19123 and vector-im/element-web#19123. + * Simplify Space Panel notification badge layout ([\#6977](https://github.com/matrix-org/matrix-react-sdk/pull/6977)). Fixes vector-im/element-web#18527 and vector-im/element-web#18527. + * Use prettier hsName during 3pid registration where possible ([\#6980](https://github.com/matrix-org/matrix-react-sdk/pull/6980)). Fixes vector-im/element-web#19162 and vector-im/element-web#19162. + +## 🐛 Bug Fixes + * Add a condition to only activate the resizer which belongs to the clicked handle ([\#7055](https://github.com/matrix-org/matrix-react-sdk/pull/7055)). Fixes vector-im/element-web#19521 and vector-im/element-web#19521. + * Restore composer focus after event edit ([\#7065](https://github.com/matrix-org/matrix-react-sdk/pull/7065)). Fixes vector-im/element-web#19469 and vector-im/element-web#19469. + * Don't apply message bubble visual style to media messages ([\#7040](https://github.com/matrix-org/matrix-react-sdk/pull/7040)). + * Handle no selected screen when screen-sharing ([\#7018](https://github.com/matrix-org/matrix-react-sdk/pull/7018)). Fixes vector-im/element-web#19460 and vector-im/element-web#19460. Contributed by @SimonBrandner. + * Add history entry before completing emoji ([\#7007](https://github.com/matrix-org/matrix-react-sdk/pull/7007)). Fixes vector-im/element-web#19177 and vector-im/element-web#19177. Contributed by @RafaelGoncalves8. + * Add padding between controls on edit form in message bubbles ([\#7039](https://github.com/matrix-org/matrix-react-sdk/pull/7039)). + * Respect the roomState right container request for the Jitsi widget ([\#7033](https://github.com/matrix-org/matrix-react-sdk/pull/7033)). Fixes vector-im/element-web#16552 and vector-im/element-web#16552. + * Fix cannot read length of undefined for room upgrades ([\#7037](https://github.com/matrix-org/matrix-react-sdk/pull/7037)). Fixes vector-im/element-web#19509 and vector-im/element-web#19509. + * Cleanup re-dispatching around timelines and composers ([\#7023](https://github.com/matrix-org/matrix-react-sdk/pull/7023)). Fixes vector-im/element-web#19491 and vector-im/element-web#19491. Contributed by @SimonBrandner. + * Fix removing a room from a Space and interaction with `m.space.parent` ([\#6944](https://github.com/matrix-org/matrix-react-sdk/pull/6944)). Fixes vector-im/element-web#19363 and vector-im/element-web#19363. + * Fix recent css regression ([\#7022](https://github.com/matrix-org/matrix-react-sdk/pull/7022)). Fixes vector-im/element-web#19470 and vector-im/element-web#19470. Contributed by @CicadaCinema. + * Fix ModalManager reRender racing with itself ([\#7027](https://github.com/matrix-org/matrix-react-sdk/pull/7027)). Fixes vector-im/element-web#19489 and vector-im/element-web#19489. + * Fix fullscreening a call while connecting ([\#7019](https://github.com/matrix-org/matrix-react-sdk/pull/7019)). Fixes vector-im/element-web#19309 and vector-im/element-web#19309. Contributed by @SimonBrandner. + * Allow scrolling right in reply-quoted code block ([\#7024](https://github.com/matrix-org/matrix-react-sdk/pull/7024)). Fixes vector-im/element-web#19487 and vector-im/element-web#19487. Contributed by @SimonBrandner. + * Fix dark theme codeblock colors ([\#6384](https://github.com/matrix-org/matrix-react-sdk/pull/6384)). Fixes vector-im/element-web#17998. Contributed by @SimonBrandner. + * Show passphrase input label ([\#6992](https://github.com/matrix-org/matrix-react-sdk/pull/6992)). Fixes vector-im/element-web#19428 and vector-im/element-web#19428. Contributed by @RafaelGoncalves8. + * Always render disabled settings as disabled ([\#7014](https://github.com/matrix-org/matrix-react-sdk/pull/7014)). + * Make "Security Phrase" placeholder look consistent cross-browser ([\#6870](https://github.com/matrix-org/matrix-react-sdk/pull/6870)). Fixes vector-im/element-web#19006 and vector-im/element-web#19006. Contributed by @neer17. + * Fix direction override characters breaking member event text direction ([\#6999](https://github.com/matrix-org/matrix-react-sdk/pull/6999)). + * Remove redundant text in verification dialogs ([\#6993](https://github.com/matrix-org/matrix-react-sdk/pull/6993)). Fixes vector-im/element-web#19290 and vector-im/element-web#19290. Contributed by @RafaelGoncalves8. + * Fix space panel name overflowing ([\#6995](https://github.com/matrix-org/matrix-react-sdk/pull/6995)). Fixes vector-im/element-web#19455 and vector-im/element-web#19455. + * Fix conflicting CSS on syntax highlighted blocks ([\#6991](https://github.com/matrix-org/matrix-react-sdk/pull/6991)). Fixes vector-im/element-web#19445 and vector-im/element-web#19445. + Changes in [3.33.0](https://github.com/vector-im/element-desktop/releases/tag/v3.33.0) (2021-10-25) =================================================================================================== diff --git a/README.md b/README.md index 4588a0586e..3ce9b316a7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ a 'skin'. A skin provides: * The containing application * Zero or more 'modules' containing non-UI functionality -As of Aug 2018, the only skin that exists is `vector-im/element-web`; it and +As of Aug 2018, the only skin that exists is [`vector-im/element-web`](https://github.com/vector-im/element-web/); it and `matrix-org/matrix-react-sdk` should effectively be considered as a single project (for instance, matrix-react-sdk bugs are currently filed against vector-im/element-web rather than this project). @@ -138,7 +138,7 @@ guide](https://classic.yarnpkg.com/docs/install) if you do not have it already. This project has not yet been migrated to Yarn 2, so please ensure `yarn --version` shows a version from the 1.x series. -`matrix-react-sdk` depends on `matrix-js-sdk`. To make use of changes in the +`matrix-react-sdk` depends on [`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk). To make use of changes in the latter and to ensure tests run against the develop branch of `matrix-js-sdk`, you should set up `matrix-js-sdk`: @@ -175,4 +175,4 @@ yarn test ## End-to-End tests Make sure you've got your Element development server running (by doing `yarn start` in element-web), and then in this project, run `yarn run e2etests`. -See `test/end-to-end-tests/README.md` for more information. +See [`test/end-to-end-tests/README.md`](https://github.com/matrix-org/matrix-react-sdk/blob/develop/test/end-to-end-tests/README.md) for more information. diff --git a/package.json b/package.json index 5d63e0a8b2..7430942e2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.33.0", + "version": "3.34.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -83,8 +83,8 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "15.0.0", - "matrix-widget-api": "^0.1.0-beta.16", + "matrix-js-sdk": "15.1.0", + "matrix-widget-api": "^0.1.0-beta.17", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", @@ -154,7 +154,7 @@ "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", - "allchange": "^1.0.3", + "allchange": "^1.0.5", "babel-jest": "^26.6.3", "chokidar": "^3.5.1", "concurrently": "^5.3.0", diff --git a/res/css/_common.scss b/res/css/_common.scss index d7f8355d81..3663b087c8 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -22,10 +22,13 @@ limitations under the License. $hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic -$EventTile_e2e_state_indicator_width: 4px; +$selected-message-border-width: 4px; $MessageTimestamp_width: 46px; /* 8 + 30 (avatar) + 8 */ -$MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $EventTile_e2e_state_indicator_width); +$MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $selected-message-border-width); + +$slider-dot-size: 1em; +$slider-selection-dot-size: 2.4em; :root { font-size: 10px; @@ -401,7 +404,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { * We should go through and have one consistent set of styles for buttons throughout the app. * For now, I am duplicating the selectors here for mx_Dialog and mx_DialogButtons. */ -.mx_Dialog button, .mx_Dialog input[type="submit"], .mx_Dialog_buttons button, .mx_Dialog_buttons input[type="submit"] { +.mx_Dialog button:not(.mx_Dialog_nonDialogButton), +.mx_Dialog input[type="submit"], +.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton), +.mx_Dialog_buttons input[type="submit"] { @mixin mx_DialogButton; margin-left: 0px; margin-right: 8px; @@ -414,36 +420,52 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { font-family: inherit; } -.mx_Dialog button:last-child { +.mx_Dialog button:not(.mx_Dialog_nonDialogButton):last-child { margin-right: 0px; } -.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover, .mx_Dialog_buttons button:hover, .mx_Dialog_buttons input[type="submit"]:hover { +.mx_Dialog button:not(.mx_Dialog_nonDialogButton):hover, +.mx_Dialog input[type="submit"]:hover, +.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):hover, +.mx_Dialog_buttons input[type="submit"]:hover { @mixin mx_DialogButton_hover; } -.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:focus, .mx_Dialog_buttons input[type="submit"]:focus { +.mx_Dialog button:not(.mx_Dialog_nonDialogButton):focus, +.mx_Dialog input[type="submit"]:focus, +.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):focus, +.mx_Dialog_buttons input[type="submit"]:focus { filter: brightness($focus-brightness); } -.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary, .mx_Dialog_buttons button.mx_Dialog_primary, .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { +.mx_Dialog button.mx_Dialog_primary, +.mx_Dialog input[type="submit"].mx_Dialog_primary, +.mx_Dialog_buttons button.mx_Dialog_primary, +.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: $accent-fg-color; background-color: $accent-color; min-width: 156px; } -.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger, .mx_Dialog_buttons button.danger, .mx_Dialog_buttons input[type="submit"].danger { +.mx_Dialog button.danger, +.mx_Dialog input[type="submit"].danger, +.mx_Dialog_buttons button.danger, +.mx_Dialog_buttons input[type="submit"].danger { background-color: $warning-color; border: solid 1px $warning-color; color: $accent-fg-color; } -.mx_Dialog button.warning, .mx_Dialog input[type="submit"].warning { +.mx_Dialog button.warning, +.mx_Dialog input[type="submit"].warning { border: solid 1px $warning-color; color: $warning-color; } -.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:disabled, .mx_Dialog_buttons input[type="submit"]:disabled { +.mx_Dialog button:not(.mx_Dialog_nonDialogButton):disabled, +.mx_Dialog input[type="submit"]:disabled, +.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):disabled, +.mx_Dialog_buttons input[type="submit"]:disabled { background-color: $light-fg-color; border: solid 1px $light-fg-color; opacity: 0.7; diff --git a/res/css/_components.scss b/res/css/_components.scss index ebf95a1c4a..116189d64c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -74,6 +74,7 @@ @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_CommunityPrototypeInviteDialog.scss"; +@import "./views/dialogs/_CompoundDialog.scss"; @import "./views/dialogs/_ConfirmSpaceUserActionDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @@ -99,6 +100,7 @@ @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; +@import "./views/dialogs/_PollCreateDialog.scss"; @import "./views/dialogs/_RegistrationEmailPromptDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @@ -200,10 +202,10 @@ @import "./views/right_panel/_EncryptionInfo.scss"; @import "./views/right_panel/_PinnedMessagesCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss"; +@import "./views/right_panel/_ThreadPanel.scss"; @import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_VerificationPanel.scss"; @import "./views/right_panel/_WidgetCard.scss"; -@import "./views/right_panel/_ThreadPanel.scss"; @import "./views/room_settings/_AliasSettings.scss"; @import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_Autocomplete.scss"; @@ -248,6 +250,7 @@ @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; +@import "./views/settings/_FontScalingPanel.scss"; @import "./views/settings/_IntegrationManager.scss"; @import "./views/settings/_JoinRuleSettings.scss"; @import "./views/settings/_LayoutSwitcher.scss"; @@ -258,6 +261,7 @@ @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; @import "./views/settings/_SpellCheckLanguages.scss"; +@import "./views/settings/_ThemeChoicePanel.scss"; @import "./views/settings/_UpdateCheckButton.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 9f2b9e24b8..88a01f220f 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -116,3 +116,11 @@ limitations under the License. border-top: 8px solid $menu-bg-color; border-right: 8px solid transparent; } + +.mx_ContextualMenu_rightAligned { + transform: translateX(-100%); +} + +.mx_ContextualMenu_bottomAligned { + transform: translateY(-100%); +} diff --git a/res/css/structures/_CreateRoom.scss b/res/css/structures/_CreateRoom.scss index 3d23ccc4b2..78e6881b10 100644 --- a/res/css/structures/_CreateRoom.scss +++ b/res/css/structures/_CreateRoom.scss @@ -34,4 +34,3 @@ limitations under the License. .mx_CreateRoom_description { width: 330px; } - diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 5ddea244f3..a658005821 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -43,8 +43,6 @@ $roomListCollapsedWidth: 68px; } } - - .mx_LeftPanel { background-color: $roomlist-bg-color; // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index b6219da9e4..0137db7ebf 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -121,7 +121,7 @@ limitations under the License. vertical-align: text-top; margin-right: 2px; content: ""; - mask: url('$(res)/img/feather-customised/user.svg'); + mask: url("$(res)/img/feather-customised/user.svg"); mask-repeat: no-repeat; mask-position: center; // scale it down and make the size slightly bigger (16 instead of 14px) @@ -132,7 +132,8 @@ limitations under the License. } } -.mx_RoomDirectory_join, .mx_RoomDirectory_preview { +.mx_RoomDirectory_join, +.mx_RoomDirectory_preview { align-self: center; white-space: nowrap; } @@ -220,3 +221,7 @@ limitations under the License. margin-top: 5px; } } + +.mx_RoomDirectory_listItem { + display: contents; +} diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index fd9c4a14fc..50fa304bd6 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -32,7 +32,6 @@ limitations under the License. position: relative; } - @keyframes mx_RoomView_fileDropTarget_animation { from { opacity: 0; @@ -112,7 +111,6 @@ limitations under the License. max-width: 1920px !important; } - .mx_RoomView .mx_MainSplit { flex: 1 1 0; } diff --git a/res/css/structures/_SpaceHierarchy.scss b/res/css/structures/_SpaceHierarchy.scss index a5d589f9c2..5735ef016d 100644 --- a/res/css/structures/_SpaceHierarchy.scss +++ b/res/css/structures/_SpaceHierarchy.scss @@ -203,7 +203,8 @@ limitations under the License. grid-row: 1; grid-column: 2; - .mx_InfoTooltip { + .mx_InfoTooltip, + .mx_SpaceHierarchy_roomTile_joined { display: inline; margin-left: 12px; color: $tertiary-content; @@ -222,6 +223,25 @@ limitations under the License. } } } + + .mx_SpaceHierarchy_roomTile_joined { + position: relative; + padding-left: 16px; + + &::before { + content: ''; + width: 20px; + height: 20px; + top: -2px; + left: -4px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + } } .mx_SpaceHierarchy_roomTile_info { @@ -268,6 +288,11 @@ limitations under the License. visibility: visible; } } + + &.mx_SpaceHierarchy_joining .mx_AccessibleButton { + visibility: visible; + padding: 4px 18px; + } } li.mx_SpaceHierarchy_roomTileWrapper { diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index c9e88d4342..4be9d49120 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -144,13 +144,7 @@ $activeBorderColor: $secondary-content; align-items: center; border-radius: 12px; padding: 4px; - } - - &:not(.mx_SpaceButton_narrow) { - .mx_SpaceButton_selectionWrapper { - width: 100%; - overflow: hidden; - } + width: 100%; } .mx_SpaceButton_name { @@ -227,7 +221,7 @@ $activeBorderColor: $secondary-content; height: 20px; margin-top: auto; margin-bottom: auto; - display: none; + visibility: hidden; position: relative; &::before { @@ -246,67 +240,45 @@ $activeBorderColor: $secondary-content; } } + .mx_SpaceButton_avatarWrapper { + position: relative; + } + .mx_SpacePanel_badgeContainer { // Create a flexbox to make aligning dot badges easier display: flex; align-items: center; + position: absolute; + right: -3px; + top: -3px; .mx_NotificationBadge { margin: 0 2px; // centering + background-clip: padding-box; } .mx_NotificationBadge_dot { // make the smaller dot occupy the same width for centering - margin: 0 7px; + margin: 0 -1px 0 0; + border: 3px solid $groupFilterPanel-bg-color; + } + + .mx_NotificationBadge_2char, + .mx_NotificationBadge_3char { + margin: -5px -5px 0 0; + border: 2px solid $groupFilterPanel-bg-color; } } - &.collapsed { - .mx_SpaceButton { - .mx_SpacePanel_badgeContainer { - position: absolute; - right: 0; - top: 0; - - .mx_NotificationBadge { - background-clip: padding-box; - } - - .mx_NotificationBadge_dot { - margin: 0 -1px 0 0; - border: 3px solid $groupFilterPanel-bg-color; - } - - .mx_NotificationBadge_2char, - .mx_NotificationBadge_3char { - margin: -5px -5px 0 0; - border: 2px solid $groupFilterPanel-bg-color; - } - } - - &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { - // when we draw the selection border we move the relative bounds of our parent - // so update our position within the bounds of the parent to maintain position overall - right: -3px; - top: -3px; - } - } + .mx_SpaceButton_narrow .mx_SpaceButton_menuButton { + display: none; } - &:not(.collapsed) { - .mx_SpaceButton:hover, - .mx_SpaceButton:focus-within, - .mx_SpaceButton_hasMenuOpen { - &:not(.mx_SpaceButton_invite) { - // Hide the badge container on hover because it'll be a menu button - .mx_SpacePanel_badgeContainer { - display: none; - } - - .mx_SpaceButton_menuButton { - display: block; - } - } + .mx_SpaceButton:hover, + .mx_SpaceButton:focus-within, + .mx_SpaceButton_hasMenuOpen { + &:not(.mx_SpaceButton_invite) .mx_SpaceButton_menuButton { + visibility: visible; } } @@ -376,7 +348,6 @@ $activeBorderColor: $secondary-content; } } - .mx_SpacePanel_sharePublicSpace { margin: 0; } diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index e116885047..51b5244c5f 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -380,45 +380,6 @@ $SpaceRoomViewInnerWidth: 428px; } } - .mx_SpaceRoomView_betaWarning { - padding: 12px 12px 12px 54px; - position: relative; - font-size: $font-15px; - line-height: $font-24px; - width: 432px; - border-radius: 8px; - background-color: $info-plinth-bg-color; - color: $secondary-content; - box-sizing: border-box; - - > h3 { - font-weight: $font-semi-bold; - font-size: inherit; - line-height: inherit; - margin: 0; - } - - > p { - font-size: inherit; - line-height: inherit; - margin: 0; - } - - &::before { - mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - content: ''; - width: 20px; - height: 20px; - position: absolute; - top: 14px; - left: 14px; - background-color: $secondary-content; - } - } - .mx_SpaceRoomView_inviteTeammates { // XXX remove this when spaces leaves Beta .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { @@ -511,10 +472,11 @@ $SpaceRoomViewInnerWidth: 428px; mask-image: url("$(res)/img/element-icons/lock.svg"); } - .mx_AccessibleButton_kind_link { + .mx_SpaceRoomView_info_memberCount { color: inherit; position: relative; - padding-left: 16px; + padding: 0 0 0 16px; + font-size: $font-15px; &::before { content: "·"; // visual separator diff --git a/res/css/views/dialogs/_CompoundDialog.scss b/res/css/views/dialogs/_CompoundDialog.scss new file mode 100644 index 0000000000..d90c7e0f8e --- /dev/null +++ b/res/css/views/dialogs/_CompoundDialog.scss @@ -0,0 +1,87 @@ +/* +Copyright 2021 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. +*/ + +// -------------------------------------------------------------------------------- +// DEV NOTE: This stylesheet covers dialogs listed by the compound, including +// over multiple React components. The actual inner contents of the dialog should +// be in their respective stylesheets. +// -------------------------------------------------------------------------------- + +// Override legacy/default styles for dialogs +.mx_Dialog_wrapper.mx_CompoundDialog > .mx_Dialog { + padding: 0; // we'll manage it ourselves + color: $primary-content; +} + +.mx_CompoundDialog { + .mx_CompoundDialog_header { + padding: 32px 32px 16px 32px; + + h1 { + display: inline-block; + font-weight: 600; + font-size: $font-24px; + margin: 0; // managed by header class + } + + .mx_CompoundDialog_cancelButton { + mask: url('$(res)/img/feather-customised/cancel.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 20px; + height: 20px; + background-color: $dialog-close-fg-color; + cursor: pointer; + + // Align with middle of title, 34px from right edge + position: absolute; + top: 34px; + right: 34px; + } + } + + .mx_CompoundDialog_content { + overflow: auto; + padding: 8px 32px; + } + + .mx_CompoundDialog_footer { + padding: 20px 32px; + text-align: right; + position: absolute; + bottom: 0; + left: 0; + right: 0; + + .mx_AccessibleButton { + margin-left: 24px; + } + } +} + +.mx_ScrollableBaseDialog { + width: 544px; // fixed + height: 516px; // fixed + + .mx_CompoundDialog_content { + height: 349px; // dialogHeight - header - footer + } + + .mx_CompoundDialog_footer { + box-shadow: 0px -4px 4px rgba(0, 0, 0, 0.05); // hardcoded colour for both themes + } +} diff --git a/res/css/views/dialogs/_JoinRuleDropdown.scss b/res/css/views/dialogs/_JoinRuleDropdown.scss index 91691cf53b..19209e9536 100644 --- a/res/css/views/dialogs/_JoinRuleDropdown.scss +++ b/res/css/views/dialogs/_JoinRuleDropdown.scss @@ -64,4 +64,3 @@ limitations under the License. mask-size: contain; } } - diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.scss b/res/css/views/dialogs/_MessageEditHistoryDialog.scss index 4574344a28..f60bbc9589 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.scss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.scss @@ -64,4 +64,3 @@ limitations under the License. padding: 0 8px; } } - diff --git a/res/css/views/dialogs/_PollCreateDialog.scss b/res/css/views/dialogs/_PollCreateDialog.scss new file mode 100644 index 0000000000..0b082a9883 --- /dev/null +++ b/res/css/views/dialogs/_PollCreateDialog.scss @@ -0,0 +1,70 @@ +/* +Copyright 2021 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_PollCreateDialog { + h2 { + font-weight: 600; + font-size: $font-15px; + line-height: $font-24px; + margin-top: 0; + margin-bottom: 8px; + + &:nth-child(n + 2) { + margin-top: 20px; + } + } + + .mx_PollCreateDialog_option { + display: flex; + align-items: center; + margin-top: 11px; + margin-bottom: 16px; // 11px from the top will collapse, so this creates a 16px gap between options + + .mx_Field { + flex: 1; + margin: 0; + } + + .mx_PollCreateDialog_removeOption { + margin-left: 12px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: $quinary-content; + cursor: pointer; + position: relative; + + &::before { + content: ""; + mask: url('$(res)/img/element-icons/x-8px.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 8px; + height: 8px; + position: absolute; + top: 6px; + left: 6px; + background-color: $secondary-content; + } + } + } + + .mx_PollCreateDialog_addOption { + padding: 0; + margin-bottom: 40px; // arbitrary to create scrollable area under the poll + } +} diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index 9bcde6e1e0..cad83e2a42 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -58,4 +58,3 @@ limitations under the License. mask-size: 36px; mask-position: center; } - diff --git a/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss b/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss index 941c8cb807..05e7f5c2e4 100644 --- a/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss +++ b/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss @@ -50,4 +50,3 @@ limitations under the License. vertical-align: middle; } } - diff --git a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss index 176919b84c..8786defed3 100644 --- a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ - .mx_WidgetCapabilitiesPromptDialog { .text-muted { font-size: $font-12px; @@ -55,7 +54,6 @@ limitations under the License. width: $font-32px; height: $font-15px; - &.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { left: calc(100% - $font-15px); } diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 98edbf8ad8..b239b7356b 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -70,7 +70,6 @@ limitations under the License. width: 300px; border: 1px solid $accent-color; border-radius: 5px; - padding: 10px; } .mx_AccessSecretStorageDialog_recoveryKeyEntry { diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 7bc47a3c98..c39fdb5f49 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -30,11 +30,13 @@ limitations under the License. align-items: center; justify-content: center; font-size: $font-14px; + border: none; // override default - @@ -367,7 +368,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { ; } - _renderPhaseKeepItSafe() { + private renderPhaseKeepItSafe(): JSX.Element { let introText; if (this.state.copied) { introText = _t( @@ -380,7 +381,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent { {}, { b: s => { s } }, ); } - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
{ introText } - +
; } - _renderBusyPhase(text) { - const Spinner = sdk.getComponent('views.elements.Spinner'); + private renderBusyPhase(): JSX.Element { return
; } - _renderPhaseDone() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + private renderPhaseDone(): JSX.Element { return

{ _t( "Your keys are being backed up (the first backup could take a few minutes).", ) }

; } - _renderPhaseOptOutConfirm() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + private renderPhaseOptOutConfirm(): JSX.Element { return
{ _t( "Without setting up Secure Message Recovery, you won't be able to restore your " + "encrypted message history if you log out or use another session.", ) } - +
; } - _titleForPhase(phase) { + private titleForPhase(phase: Phase): string { switch (phase) { - case PHASE_PASSPHRASE: + case Phase.Passphrase: return _t('Secure your backup with a Security Phrase'); - case PHASE_PASSPHRASE_CONFIRM: + case Phase.PassphraseConfirm: return _t('Confirm your Security Phrase'); - case PHASE_OPTOUT_CONFIRM: + case Phase.OptOutConfirm: return _t('Warning!'); - case PHASE_SHOWKEY: - case PHASE_KEEPITSAFE: + case Phase.ShowKey: + case Phase.KeepItSafe: return _t('Make a copy of your Security Key'); - case PHASE_BACKINGUP: + case Phase.BackingUp: return _t('Starting backup...'); - case PHASE_DONE: + case Phase.Done: return _t('Success!'); default: return _t("Create key backup"); } } - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render(): JSX.Element { let content; if (this.state.error) { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); content =

{ _t("Unable to create key backup") }

; } else { switch (this.state.phase) { - case PHASE_PASSPHRASE: - content = this._renderPhasePassPhrase(); + case Phase.Passphrase: + content = this.renderPhasePassPhrase(); break; - case PHASE_PASSPHRASE_CONFIRM: - content = this._renderPhasePassPhraseConfirm(); + case Phase.PassphraseConfirm: + content = this.renderPhasePassPhraseConfirm(); break; - case PHASE_SHOWKEY: - content = this._renderPhaseShowKey(); + case Phase.ShowKey: + content = this.renderPhaseShowKey(); break; - case PHASE_KEEPITSAFE: - content = this._renderPhaseKeepItSafe(); + case Phase.KeepItSafe: + content = this.renderPhaseKeepItSafe(); break; - case PHASE_BACKINGUP: - content = this._renderBusyPhase(); + case Phase.BackingUp: + content = this.renderBusyPhase(); break; - case PHASE_DONE: - content = this._renderPhaseDone(); + case Phase.Done: + content = this.renderPhaseDone(); break; - case PHASE_OPTOUT_CONFIRM: - content = this._renderPhaseOptOutConfirm(); + case Phase.OptOutConfirm: + content = this.renderPhaseOptOutConfirm(); break; } } @@ -497,8 +491,8 @@ export default class CreateKeyBackupDialog extends React.PureComponent { return (
{ content } diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx similarity index 66% rename from src/async-components/views/dialogs/security/CreateSecretStorageDialog.js rename to src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 7a21b7075b..0cee45fbc0 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -16,8 +16,6 @@ limitations under the License. */ import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../../index'; import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; import { _t, _td } from '../../../../languageHandler'; @@ -31,52 +29,105 @@ import AccessibleButton from "../../../../components/views/elements/AccessibleBu import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; -import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; +import { + getSecureBackupSetupMethods, + isSecureBackupRequired, + SecureBackupSetupMethod, +} from '../../../../utils/WellKnownUtils'; import SecurityCustomisations from "../../../../customisations/Security"; import { logger } from "matrix-js-sdk/src/logger"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import Field from "../../../../components/views/elements/Field"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Spinner from "../../../../components/views/elements/Spinner"; +import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; +import { CrossSigningKeys } from "matrix-js-sdk"; +import InteractiveAuthDialog from "../../../../components/views/dialogs/InteractiveAuthDialog"; +import { IRecoveryKey } from "matrix-js-sdk/src/crypto/api"; +import { IValidationResult } from "../../../../components/views/elements/Validation"; -const PHASE_LOADING = 0; -const PHASE_LOADERROR = 1; -const PHASE_CHOOSE_KEY_PASSPHRASE = 2; -const PHASE_MIGRATE = 3; -const PHASE_PASSPHRASE = 4; -const PHASE_PASSPHRASE_CONFIRM = 5; -const PHASE_SHOWKEY = 6; -const PHASE_STORING = 8; -const PHASE_CONFIRM_SKIP = 10; +// I made a mistake while converting this and it has to be fixed! +enum Phase { + Loading = "loading", + LoadError = "load_error", + ChooseKeyPassphrase = "choose_key_passphrase", + Migrate = "migrate", + Passphrase = "passphrase", + PassphraseConfirm = "passphrase_confirm", + ShowKey = "show_key", + Storing = "storing", + ConfirmSkip = "confirm_skip", +} const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. -// these end up as strings from being values in the radio buttons, so just use strings -const CREATE_STORAGE_OPTION_KEY = 'key'; -const CREATE_STORAGE_OPTION_PASSPHRASE = 'passphrase'; +interface IProps extends IDialogProps { + hasCancel: boolean; + accountPassword: string; + forceReset: boolean; +} + +interface IState { + phase: Phase; + passPhrase: string; + passPhraseValid: boolean; + passPhraseConfirm: string; + copied: boolean; + downloaded: boolean; + setPassphrase: boolean; + backupInfo: IKeyBackupInfo; + backupSigStatus: TrustInfo; + // does the server offer a UI auth flow with just m.login.password + // for /keys/device_signing/upload? + canUploadKeysWithPasswordOnly: boolean; + accountPassword: string; + accountPasswordCorrect: boolean; + canSkip: boolean; + passPhraseKeySelected: string; + error?: string; +} /* * Walks the user through the process of creating a passphrase to guard Secure * Secret Storage in account data. */ -export default class CreateSecretStorageDialog extends React.PureComponent { - static propTypes = { - hasCancel: PropTypes.bool, - accountPassword: PropTypes.string, - forceReset: PropTypes.bool, - }; - - static defaultProps = { +export default class CreateSecretStorageDialog extends React.PureComponent { + public static defaultProps: Partial = { hasCancel: true, forceReset: false, }; + private recoveryKey: IRecoveryKey; + private backupKey: Uint8Array; + private recoveryKeyNode = createRef(); + private passphraseField = createRef(); - constructor(props) { + constructor(props: IProps) { super(props); - this._recoveryKey = null; - this._recoveryKeyNode = null; - this._backupKey = null; + let passPhraseKeySelected; + const setupMethods = getSecureBackupSetupMethods(); + if (setupMethods.includes(SecureBackupSetupMethod.Key)) { + passPhraseKeySelected = SecureBackupSetupMethod.Key; + } else { + passPhraseKeySelected = SecureBackupSetupMethod.Passphrase; + } + + const accountPassword = props.accountPassword || ""; + let canUploadKeysWithPasswordOnly = null; + if (accountPassword) { + // If we have an account password in memory, let's simplify and + // assume it means password auth is also supported for device + // signing key upload as well. This avoids hitting the server to + // test auth flows, which may be slow under high load. + canUploadKeysWithPasswordOnly = true; + } else { + this.queryKeyUploadAuth(); + } this.state = { - phase: PHASE_LOADING, + phase: Phase.Loading, passPhrase: '', passPhraseValid: false, passPhraseConfirm: '', @@ -87,55 +138,37 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus: null, // does the server offer a UI auth flow with just m.login.password // for /keys/device_signing/upload? - canUploadKeysWithPasswordOnly: null, - accountPassword: props.accountPassword || "", accountPasswordCorrect: null, canSkip: !isSecureBackupRequired(), + canUploadKeysWithPasswordOnly, + passPhraseKeySelected, + accountPassword, }; - const setupMethods = getSecureBackupSetupMethods(); - if (setupMethods.includes("key")) { - this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_KEY; - } else { - this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_PASSPHRASE; - } + MatrixClientPeg.get().on('crypto.keyBackupStatus', this.onKeyBackupStatusChange); - this._passphraseField = createRef(); - - MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); - - if (this.state.accountPassword) { - // If we have an account password in memory, let's simplify and - // assume it means password auth is also supported for device - // signing key upload as well. This avoids hitting the server to - // test auth flows, which may be slow under high load. - this.state.canUploadKeysWithPasswordOnly = true; - } else { - this._queryKeyUploadAuth(); - } - - this._getInitialPhase(); + this.getInitialPhase(); } - componentWillUnmount() { - MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + public componentWillUnmount(): void { + MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this.onKeyBackupStatusChange); } - _getInitialPhase() { + private getInitialPhase(): void { const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.(); if (keyFromCustomisations) { logger.log("Created key via customisations, jumping to bootstrap step"); - this._recoveryKey = { + this.recoveryKey = { privateKey: keyFromCustomisations, }; - this._bootstrapSecretStorage(); + this.bootstrapSecretStorage(); return; } - this._fetchBackupInfo(); + this.fetchBackupInfo(); } - async _fetchBackupInfo() { + private async fetchBackupInfo(): Promise<{ backupInfo: IKeyBackupInfo, backupSigStatus: TrustInfo }> { try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupSigStatus = ( @@ -144,7 +177,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); const { forceReset } = this.props; - const phase = (backupInfo && !forceReset) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; + const phase = (backupInfo && !forceReset) ? Phase.Migrate : Phase.ChooseKeyPassphrase; this.setState({ phase, @@ -157,13 +190,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({ phase: PHASE_LOADERROR }); + this.setState({ phase: Phase.LoadError }); } } - async _queryKeyUploadAuth() { + private async queryKeyUploadAuth(): Promise { try { - await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); + await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {} as CrossSigningKeys); // We should never get here: the server should always require // UI auth to upload device signing keys. If we do, we upload // no keys which would be a no-op. @@ -182,59 +215,55 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } - _onKeyBackupStatusChange = () => { - if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); - } + private onKeyBackupStatusChange = (): void => { + if (this.state.phase === Phase.Migrate) this.fetchBackupInfo(); + }; - _onKeyPassphraseChange = e => { + private onKeyPassphraseChange = (e: React.ChangeEvent): void => { this.setState({ passPhraseKeySelected: e.target.value, }); - } + }; - _collectRecoveryKeyNode = (n) => { - this._recoveryKeyNode = n; - } - - _onChooseKeyPassphraseFormSubmit = async () => { - if (this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY) { - this._recoveryKey = + private onChooseKeyPassphraseFormSubmit = async (): Promise => { + if (this.state.passPhraseKeySelected === SecureBackupSetupMethod.Key) { + this.recoveryKey = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); this.setState({ copied: false, downloaded: false, setPassphrase: false, - phase: PHASE_SHOWKEY, + phase: Phase.ShowKey, }); } else { this.setState({ copied: false, downloaded: false, - phase: PHASE_PASSPHRASE, + phase: Phase.Passphrase, }); } - } + }; - _onMigrateFormSubmit = (e) => { + private onMigrateFormSubmit = (e: React.FormEvent): void => { e.preventDefault(); if (this.state.backupSigStatus.usable) { - this._bootstrapSecretStorage(); + this.bootstrapSecretStorage(); } else { - this._restoreBackup(); + this.restoreBackup(); } - } + }; - _onCopyClick = () => { - const successful = copyNode(this._recoveryKeyNode); + private onCopyClick = (): void => { + const successful = copyNode(this.recoveryKeyNode.current); if (successful) { this.setState({ copied: true, }); } - } + }; - _onDownloadClick = () => { - const blob = new Blob([this._recoveryKey.encodedPrivateKey], { + private onDownloadClick = (): void => { + const blob = new Blob([this.recoveryKey.encodedPrivateKey], { type: 'text/plain;charset=us-ascii', }); FileSaver.saveAs(blob, 'security-key.txt'); @@ -242,9 +271,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.setState({ downloaded: true, }); - } + }; - _doBootstrapUIAuth = async (makeRequest) => { + private doBootstrapUIAuth = async (makeRequest: (authData: any) => void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { await makeRequest({ type: 'm.login.password', @@ -258,8 +287,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { password: this.state.accountPassword, }); } else { - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { title: _t("Use Single Sign On to continue"), @@ -292,11 +319,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { throw new Error("Cross-signing key upload auth canceled"); } } - } + }; - _bootstrapSecretStorage = async () => { + private bootstrapSecretStorage = async (): Promise => { this.setState({ - phase: PHASE_STORING, + phase: Phase.Storing, error: null, }); @@ -308,7 +335,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { if (forceReset) { logger.log("Forcing secret storage reset"); await cli.bootstrapSecretStorage({ - createSecretStorageKey: async () => this._recoveryKey, + createSecretStorageKey: async () => this.recoveryKey, setupNewKeyBackup: true, setupNewSecretStorage: true, }); @@ -321,18 +348,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // keys (and also happen to skip all post-authentication flows at the // moment via token login) await cli.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + authUploadDeviceSigningKeys: this.doBootstrapUIAuth, }); await cli.bootstrapSecretStorage({ - createSecretStorageKey: async () => this._recoveryKey, + createSecretStorageKey: async () => this.recoveryKey, keyBackupInfo: this.state.backupInfo, setupNewKeyBackup: !this.state.backupInfo, - getKeyBackupPassphrase: () => { + getKeyBackupPassphrase: async () => { // We may already have the backup key if we earlier went // through the restore backup path, so pass it along // rather than prompting again. - if (this._backupKey) { - return this._backupKey; + if (this.backupKey) { + return this.backupKey; } return promptForBackupPassphrase(); }, @@ -344,27 +371,23 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.setState({ accountPassword: '', accountPasswordCorrect: false, - phase: PHASE_MIGRATE, + phase: Phase.Migrate, }); } else { this.setState({ error: e }); } logger.error("Error bootstrapping secret storage", e); } - } + }; - _onCancel = () => { + private onCancel = (): void => { this.props.onFinished(false); - } + }; - _onDone = () => { - this.props.onFinished(true); - } - - _restoreBackup = async () => { + private restoreBackup = async (): Promise => { // It's possible we'll need the backup key later on for bootstrapping, // so let's stash it here, rather than prompting for it twice. - const keyCallback = k => this._backupKey = k; + const keyCallback = k => this.backupKey = k; const { finished } = Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, @@ -376,122 +399,122 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); await finished; - const { backupSigStatus } = await this._fetchBackupInfo(); + const { backupSigStatus } = await this.fetchBackupInfo(); if ( backupSigStatus.usable && this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword ) { - this._bootstrapSecretStorage(); + this.bootstrapSecretStorage(); } - } + }; - _onLoadRetryClick = () => { - this.setState({ phase: PHASE_LOADING }); - this._fetchBackupInfo(); - } + private onLoadRetryClick = (): void => { + this.setState({ phase: Phase.Loading }); + this.fetchBackupInfo(); + }; - _onShowKeyContinueClick = () => { - this._bootstrapSecretStorage(); - } + private onShowKeyContinueClick = (): void => { + this.bootstrapSecretStorage(); + }; - _onCancelClick = () => { - this.setState({ phase: PHASE_CONFIRM_SKIP }); - } + private onCancelClick = (): void => { + this.setState({ phase: Phase.ConfirmSkip }); + }; - _onGoBackClick = () => { - this.setState({ phase: PHASE_CHOOSE_KEY_PASSPHRASE }); - } + private onGoBackClick = (): void => { + this.setState({ phase: Phase.ChooseKeyPassphrase }); + }; - _onPassPhraseNextClick = async (e) => { + private onPassPhraseNextClick = async (e: React.FormEvent) => { e.preventDefault(); - if (!this._passphraseField.current) return; // unmounting + if (!this.passphraseField.current) return; // unmounting - await this._passphraseField.current.validate({ allowEmpty: false }); - if (!this._passphraseField.current.state.valid) { - this._passphraseField.current.focus(); - this._passphraseField.current.validate({ allowEmpty: false, focused: true }); + await this.passphraseField.current.validate({ allowEmpty: false }); + if (!this.passphraseField.current.state.valid) { + this.passphraseField.current.focus(); + this.passphraseField.current.validate({ allowEmpty: false, focused: true }); return; } - this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); + this.setState({ phase: Phase.PassphraseConfirm }); }; - _onPassPhraseConfirmNextClick = async (e) => { + private onPassPhraseConfirmNextClick = async (e: React.FormEvent) => { e.preventDefault(); if (this.state.passPhrase !== this.state.passPhraseConfirm) return; - this._recoveryKey = + this.recoveryKey = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); this.setState({ copied: false, downloaded: false, setPassphrase: true, - phase: PHASE_SHOWKEY, + phase: Phase.ShowKey, }); - } + }; - _onSetAgainClick = () => { + private onSetAgainClick = (): void => { this.setState({ passPhrase: '', passPhraseValid: false, passPhraseConfirm: '', - phase: PHASE_PASSPHRASE, + phase: Phase.Passphrase, }); - } + }; - _onPassPhraseValidate = (result) => { + private onPassPhraseValidate = (result: IValidationResult): void => { this.setState({ passPhraseValid: result.valid, }); }; - _onPassPhraseChange = (e) => { + private onPassPhraseChange = (e: React.ChangeEvent): void => { this.setState({ passPhrase: e.target.value, }); - } + }; - _onPassPhraseConfirmChange = (e) => { + private onPassPhraseConfirmChange = (e: React.ChangeEvent): void => { this.setState({ passPhraseConfirm: e.target.value, }); - } + }; - _onAccountPasswordChange = (e) => { + private onAccountPasswordChange = (e: React.ChangeEvent): void => { this.setState({ accountPassword: e.target.value, }); - } + }; - _renderOptionKey() { + private renderOptionKey(): JSX.Element { return (
{ _t("Generate a Security Key") }
-
{ _t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }
+
{ _t("We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }
); } - _renderOptionPassphrase() { + private renderOptionPassphrase(): JSX.Element { return (
@@ -503,12 +526,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); } - _renderPhaseChooseKeyPassphrase() { + private renderPhaseChooseKeyPassphrase(): JSX.Element { const setupMethods = getSecureBackupSetupMethods(); - const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null; - const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null; + const optionKey = setupMethods.includes(SecureBackupSetupMethod.Key) ? this.renderOptionKey() : null; + const optionPassphrase = setupMethods.includes(SecureBackupSetupMethod.Passphrase) + ? this.renderOptionPassphrase() + : null; - return
+ return

{ _t( "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", @@ -519,20 +544,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

; } - _renderPhaseMigrate() { + private renderPhaseMigrate(): JSX.Element { // TODO: This is a temporary screen so people who have the labs flag turned on and // click the button are aware they're making a change to their account. // Once we're confident enough in this (and it's supported enough) we can do // it automatically. // https://github.com/vector-im/element-web/issues/11696 - const Field = sdk.getComponent('views.elements.Field'); let authPrompt; let nextCaption = _t("Next"); @@ -543,7 +567,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { type="password" label={_t("Password")} value={this.state.accountPassword} - onChange={this._onAccountPasswordChange} + onChange={this.onAccountPasswordChange} forceValidity={this.state.accountPasswordCorrect === false ? false : null} autoFocus={true} />
@@ -559,7 +583,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

; } - return
+ return

{ _t( "Upgrade this session to allow it to verify other sessions, " + "granting them access to encrypted messages and marking them " + @@ -568,32 +592,32 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

{ authPrompt }
-
; } - _renderPhasePassPhrase() { - return
+ private renderPhasePassPhrase(): JSX.Element { + return

{ _t( - "Enter a security phrase only you know, as it’s used to safeguard your data. " + - "To be secure, you shouldn’t re-use your account password.", + "Enter a security phrase only you know, as it's used to safeguard your data. " + + "To be secure, you shouldn't re-use your account password.", ) }

; } - _renderPhasePassPhraseConfirm() { - const Field = sdk.getComponent('views.elements.Field'); - + private renderPhasePassPhraseConfirm(): JSX.Element { let matchText; let changeText; if (this.state.passPhraseConfirm === this.state.passPhrase) { @@ -641,20 +663,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent { passPhraseMatch =
{ matchText }
- + { changeText }
; } - return
+ return

{ _t( "Enter your Security Phrase a second time to confirm it.", ) }

; } - _renderPhaseShowKey() { + private renderPhaseShowKey(): JSX.Element { let continueButton; - if (this.state.phase === PHASE_SHOWKEY) { + if (this.state.phase === Phase.ShowKey) { continueButton = ; } else { @@ -695,18 +717,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return

{ _t( "Store your Security Key somewhere safe, like a password manager or a safe, " + - "as it’s used to safeguard your encrypted data.", + "as it's used to safeguard your encrypted data.", ) }

- { this._recoveryKey.encodedPrivateKey } + { this.recoveryKey.encodedPrivateKey }
{ _t("Download") } @@ -714,8 +736,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { { this.state.copied ? _t("Copied!") : _t("Copy") } @@ -726,27 +748,26 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } - _renderBusyPhase() { - const Spinner = sdk.getComponent('views.elements.Spinner'); + private renderBusyPhase(): JSX.Element { return
; } - _renderPhaseLoadError() { + private renderPhaseLoadError(): JSX.Element { return

{ _t("Unable to query secret storage status") }

; } - _renderPhaseSkipConfirm() { + private renderPhaseSkipConfirm(): JSX.Element { return

{ _t( "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", @@ -755,98 +776,96 @@ export default class CreateSecretStorageDialog extends React.PureComponent { "You can also set up Secure Backup & manage your keys in Settings.", ) }

- +
; } - _titleForPhase(phase) { + private titleForPhase(phase: Phase): string { switch (phase) { - case PHASE_CHOOSE_KEY_PASSPHRASE: + case Phase.ChooseKeyPassphrase: return _t('Set up Secure Backup'); - case PHASE_MIGRATE: + case Phase.Migrate: return _t('Upgrade your encryption'); - case PHASE_PASSPHRASE: + case Phase.Passphrase: return _t('Set a Security Phrase'); - case PHASE_PASSPHRASE_CONFIRM: + case Phase.PassphraseConfirm: return _t('Confirm Security Phrase'); - case PHASE_CONFIRM_SKIP: + case Phase.ConfirmSkip: return _t('Are you sure?'); - case PHASE_SHOWKEY: + case Phase.ShowKey: return _t('Save your Security Key'); - case PHASE_STORING: + case Phase.Storing: return _t('Setting up keys'); default: return ''; } } - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render(): JSX.Element { let content; if (this.state.error) { content =

{ _t("Unable to set up secret storage") }

; } else { switch (this.state.phase) { - case PHASE_LOADING: - content = this._renderBusyPhase(); + case Phase.Loading: + content = this.renderBusyPhase(); break; - case PHASE_LOADERROR: - content = this._renderPhaseLoadError(); + case Phase.LoadError: + content = this.renderPhaseLoadError(); break; - case PHASE_CHOOSE_KEY_PASSPHRASE: - content = this._renderPhaseChooseKeyPassphrase(); + case Phase.ChooseKeyPassphrase: + content = this.renderPhaseChooseKeyPassphrase(); break; - case PHASE_MIGRATE: - content = this._renderPhaseMigrate(); + case Phase.Migrate: + content = this.renderPhaseMigrate(); break; - case PHASE_PASSPHRASE: - content = this._renderPhasePassPhrase(); + case Phase.Passphrase: + content = this.renderPhasePassPhrase(); break; - case PHASE_PASSPHRASE_CONFIRM: - content = this._renderPhasePassPhraseConfirm(); + case Phase.PassphraseConfirm: + content = this.renderPhasePassPhraseConfirm(); break; - case PHASE_SHOWKEY: - content = this._renderPhaseShowKey(); + case Phase.ShowKey: + content = this.renderPhaseShowKey(); break; - case PHASE_STORING: - content = this._renderBusyPhase(); + case Phase.Storing: + content = this.renderBusyPhase(); break; - case PHASE_CONFIRM_SKIP: - content = this._renderPhaseSkipConfirm(); + case Phase.ConfirmSkip: + content = this.renderPhaseSkipConfirm(); break; } } let titleClass = null; switch (this.state.phase) { - case PHASE_PASSPHRASE: - case PHASE_PASSPHRASE_CONFIRM: + case Phase.Passphrase: + case Phase.PassphraseConfirm: titleClass = [ 'mx_CreateSecretStorageDialog_titleWithIcon', 'mx_CreateSecretStorageDialog_securePhraseTitle', ]; break; - case PHASE_SHOWKEY: + case Phase.ShowKey: titleClass = [ 'mx_CreateSecretStorageDialog_titleWithIcon', 'mx_CreateSecretStorageDialog_secureBackupTitle', ]; break; - case PHASE_CHOOSE_KEY_PASSPHRASE: + case Phase.ChooseKeyPassphrase: titleClass = 'mx_CreateSecretStorageDialog_centeredTitle'; break; } @@ -854,9 +873,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return (
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx similarity index 79% rename from src/async-components/views/dialogs/security/ExportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx index c21e17a7a1..2ba78da90e 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx @@ -16,47 +16,51 @@ limitations under the License. import FileSaver from 'file-saver'; import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../../index'; - +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import { logger } from "matrix-js-sdk/src/logger"; -const PHASE_EDIT = 1; -const PHASE_EXPORTING = 2; +enum Phase { + Edit = "edit", + Exporting = "exporting", +} -export default class ExportE2eKeysDialog extends React.Component { - static propTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, - onFinished: PropTypes.func.isRequired, - }; +interface IProps extends IDialogProps { + matrixClient: MatrixClient; +} - constructor(props) { +interface IState { + phase: Phase; + errStr: string; +} + +export default class ExportE2eKeysDialog extends React.Component { + private unmounted = false; + private passphrase1 = createRef(); + private passphrase2 = createRef(); + + constructor(props: IProps) { super(props); - this._unmounted = false; - - this._passphrase1 = createRef(); - this._passphrase2 = createRef(); - this.state = { - phase: PHASE_EDIT, + phase: Phase.Edit, errStr: null, }; } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount(): void { + this.unmounted = true; } - _onPassphraseFormSubmit = (ev) => { + private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - const passphrase = this._passphrase1.current.value; - if (passphrase !== this._passphrase2.current.value) { + const passphrase = this.passphrase1.current.value; + if (passphrase !== this.passphrase2.current.value) { this.setState({ errStr: _t('Passphrases must match') }); return false; } @@ -65,11 +69,11 @@ export default class ExportE2eKeysDialog extends React.Component { return false; } - this._startExport(passphrase); + this.startExport(passphrase); return false; }; - _startExport(passphrase) { + private startExport(passphrase: string): void { // extra Promise.resolve() to turn synchronous exceptions into // asynchronous ones. Promise.resolve().then(() => { @@ -86,39 +90,37 @@ export default class ExportE2eKeysDialog extends React.Component { this.props.onFinished(true); }).catch((e) => { logger.error("Error exporting e2e keys:", e); - if (this._unmounted) { + if (this.unmounted) { return; } const msg = e.friendlyText || _t('Unknown error'); this.setState({ errStr: msg, - phase: PHASE_EDIT, + phase: Phase.Edit, }); }); this.setState({ errStr: null, - phase: PHASE_EXPORTING, + phase: Phase.Exporting, }); } - _onCancelClick = (ev) => { + private onCancelClick = (ev: React.MouseEvent): boolean => { ev.preventDefault(); this.props.onFinished(false); return false; }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - - const disableForm = (this.state.phase === PHASE_EXPORTING); + public render(): JSX.Element { + const disableForm = (this.state.phase === Phase.Exporting); return ( -
+

{ _t( @@ -151,10 +153,10 @@ export default class ExportE2eKeysDialog extends React.Component {

@@ -167,9 +169,9 @@ export default class ExportE2eKeysDialog extends React.Component {
- @@ -184,7 +186,7 @@ export default class ExportE2eKeysDialog extends React.Component { value={_t('Export')} disabled={disableForm} /> -
diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx similarity index 74% rename from src/async-components/views/dialogs/security/ImportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx index 51d2861396..fccc730812 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx @@ -15,20 +15,19 @@ limitations under the License. */ import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../../index'; import { _t } from '../../../../languageHandler'; - +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import { logger } from "matrix-js-sdk/src/logger"; -function readFileAsArrayBuffer(file) { +function readFileAsArrayBuffer(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { - resolve(e.target.result); + resolve(e.target.result as ArrayBuffer); }; reader.onerror = reject; @@ -36,51 +35,57 @@ function readFileAsArrayBuffer(file) { }); } -const PHASE_EDIT = 1; -const PHASE_IMPORTING = 2; +enum Phase { + Edit = "edit", + Importing = "importing", +} -export default class ImportE2eKeysDialog extends React.Component { - static propTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, - onFinished: PropTypes.func.isRequired, - }; +interface IProps extends IDialogProps { + matrixClient: MatrixClient; +} - constructor(props) { +interface IState { + enableSubmit: boolean; + phase: Phase; + errStr: string; +} + +export default class ImportE2eKeysDialog extends React.Component { + private unmounted = false; + private file = createRef(); + private passphrase = createRef(); + + constructor(props: IProps) { super(props); - this._unmounted = false; - - this._file = createRef(); - this._passphrase = createRef(); - this.state = { enableSubmit: false, - phase: PHASE_EDIT, + phase: Phase.Edit, errStr: null, }; } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount(): void { + this.unmounted = true; } - _onFormChange = (ev) => { - const files = this._file.current.files || []; + private onFormChange = (ev: React.FormEvent): void => { + const files = this.file.current.files || []; this.setState({ - enableSubmit: (this._passphrase.current.value !== "" && files.length > 0), + enableSubmit: (this.passphrase.current.value !== "" && files.length > 0), }); }; - _onFormSubmit = (ev) => { + private onFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - this._startImport(this._file.current.files[0], this._passphrase.current.value); + this.startImport(this.file.current.files[0], this.passphrase.current.value); return false; }; - _startImport(file, passphrase) { + private startImport(file: File, passphrase: string) { this.setState({ errStr: null, - phase: PHASE_IMPORTING, + phase: Phase.Importing, }); return readFileAsArrayBuffer(file).then((arrayBuffer) => { @@ -94,34 +99,32 @@ export default class ImportE2eKeysDialog extends React.Component { this.props.onFinished(true); }).catch((e) => { logger.error("Error importing e2e keys:", e); - if (this._unmounted) { + if (this.unmounted) { return; } const msg = e.friendlyText || _t('Unknown error'); this.setState({ errStr: msg, - phase: PHASE_EDIT, + phase: Phase.Edit, }); }); } - _onCancelClick = (ev) => { + private onCancelClick = (ev: React.MouseEvent): boolean => { ev.preventDefault(); this.props.onFinished(false); return false; }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - - const disableForm = (this.state.phase !== PHASE_EDIT); + public render(): JSX.Element { + const disableForm = (this.state.phase !== Phase.Edit); return ( - +

{ _t( @@ -149,11 +152,11 @@ export default class ImportE2eKeysDialog extends React.Component {

@@ -165,11 +168,11 @@ export default class ImportE2eKeysDialog extends React.Component {
@@ -182,7 +185,7 @@ export default class ImportE2eKeysDialog extends React.Component { value={_t('Import')} disabled={!this.state.enableSubmit || disableForm} /> -
diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx similarity index 84% rename from src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js rename to src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx index 263d25c98c..105d12f3d7 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx @@ -16,43 +16,40 @@ limitations under the License. */ import React from "react"; -import PropTypes from "prop-types"; -import * as sdk from "../../../../index"; import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import { Action } from "../../../../dispatcher/actions"; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; -export default class NewRecoveryMethodDialog extends React.PureComponent { - static propTypes = { - // As returned by js-sdk getKeyBackupVersion() - newVersionInfo: PropTypes.object, - onFinished: PropTypes.func.isRequired, - } +interface IProps extends IDialogProps { + newVersionInfo: IKeyBackupInfo; +} - onOkClick = () => { +export default class NewRecoveryMethodDialog extends React.PureComponent { + private onOkClick = (): void => { this.props.onFinished(); - } + }; - onGoToSettingsClick = () => { + private onGoToSettingsClick = (): void => { this.props.onFinished(); dis.fire(Action.ViewUserSettings); - } + }; - onSetupClick = async () => { + private onSetupClick = async (): Promise => { Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, { onFinished: this.props.onFinished, }, null, /* priority = */ false, /* static = */ true, ); - } - - render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); + }; + public render(): JSX.Element { const title = { _t("New Recovery Method") } ; diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx similarity index 82% rename from src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js rename to src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx index f586c9430a..8ed6eb233e 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx @@ -15,36 +15,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import PropTypes from "prop-types"; -import * as sdk from "../../../../index"; +import React, { ComponentType } from "react"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; import { Action } from "../../../../dispatcher/actions"; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; -export default class RecoveryMethodRemovedDialog extends React.PureComponent { - static propTypes = { - onFinished: PropTypes.func.isRequired, - } +interface IProps extends IDialogProps {} - onGoToSettingsClick = () => { +export default class RecoveryMethodRemovedDialog extends React.PureComponent { + private onGoToSettingsClick = (): void => { this.props.onFinished(); dis.fire(Action.ViewUserSettings); - } + }; - onSetupClick = () => { + private onSetupClick = (): void => { this.props.onFinished(); Modal.createTrackedDialogAsync("Key Backup", "Key Backup", - import("./CreateKeyBackupDialog"), + import("./CreateKeyBackupDialog") as unknown as Promise>, null, null, /* priority = */ false, /* static = */ true, ); - } - - render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); + }; + public render(): JSX.Element { const title = { _t("Recovery Method Removed") } ; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 4250b5925b..94b4b46fd4 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -49,6 +49,8 @@ export interface IPosition { bottom?: number; left?: number; right?: number; + rightAligned?: boolean; + bottomAligned?: boolean; } export enum ChevronFace { @@ -249,6 +251,8 @@ export class ContextMenu extends React.PureComponent { let handled = true; switch (ev.key) { + // XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils + // to inherit proper handling of unmount edge cases case Key.TAB: case Key.ESCAPE: case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a @@ -344,6 +348,8 @@ export class ContextMenu extends React.PureComponent { 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, + 'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, + 'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, }); const menuStyle: CSSProperties = {}; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 9a2ebd45e2..f12b4cbcf5 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -40,6 +40,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; import UIStore from "../../stores/UIStore"; +import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex"; interface IProps { isMinimized: boolean; @@ -51,19 +52,12 @@ interface IState { activeSpace?: Room; } -// List of CSS classes which should be included in keyboard navigation within the room list -const cssClasses = [ - "mx_RoomSearch_input", - "mx_RoomSearch_minimizedHandle", // minimized - "mx_RoomSublist_headerText", - "mx_RoomTile", - "mx_RoomSublist_showNButton", -]; - @replaceableComponent("structures.LeftPanel") export default class LeftPanel extends React.Component { - private ref: React.RefObject = createRef(); - private listContainerRef: React.RefObject = createRef(); + private ref = createRef(); + private listContainerRef = createRef(); + private roomSearchRef = createRef(); + private roomListRef = createRef(); private focusedElement = null; private isDoingStickyHeaders = false; @@ -283,16 +277,25 @@ export default class LeftPanel extends React.Component { this.focusedElement = null; }; - private onKeyDown = (ev: React.KeyboardEvent) => { + private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState) => { if (!this.focusedElement) return; const action = getKeyBindingsManager().getRoomListAction(ev); switch (action) { case RoomListAction.NextRoom: + if (!state) { + ev.stopPropagation(); + ev.preventDefault(); + this.roomListRef.current?.focus(); + } + break; + case RoomListAction.PrevRoom: - ev.stopPropagation(); - ev.preventDefault(); - this.onMoveFocus(action === RoomListAction.PrevRoom); + if (state && state.activeRef === findSiblingElement(state.refs, 0)) { + ev.stopPropagation(); + ev.preventDefault(); + this.roomSearchRef.current?.focus(); + } break; } }; @@ -305,45 +308,6 @@ export default class LeftPanel extends React.Component { } }; - private onMoveFocus = (up: boolean) => { - let element = this.focusedElement; - - let descending = false; // are we currently descending or ascending through the DOM tree? - let classes: DOMTokenList; - - do { - const child = up ? element.lastElementChild : element.firstElementChild; - const sibling = up ? element.previousElementSibling : element.nextElementSibling; - - if (descending) { - if (child) { - element = child; - } else if (sibling) { - element = sibling; - } else { - descending = false; - element = element.parentElement; - } - } else { - if (sibling) { - element = sibling; - descending = true; - } else { - element = element.parentElement; - } - } - - if (element) { - classes = element.classList; - } - } while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null)); - - if (element) { - element.focus(); - this.focusedElement = element; - } - }; - private renderHeader(): React.ReactNode { return (
@@ -388,7 +352,7 @@ export default class LeftPanel extends React.Component { > @@ -417,6 +381,7 @@ export default class LeftPanel extends React.Component { activeSpace={this.state.activeSpace} onResize={this.refreshStickyHeaders} onListCollapse={this.refreshStickyHeaders} + ref={this.roomListRef} />; const containerClasses = classNames({ diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 84e0b446f5..566e14e633 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -108,6 +108,7 @@ interface IProps { currentGroupIsNew?: boolean; justRegistered?: boolean; roomJustCreatedOpts?: IOpts; + forceTimeline?: boolean; // see props on MatrixChat } interface IUsageLimit { @@ -611,6 +612,7 @@ class LoggedInView extends React.Component { key={this.props.currentRoomId || 'roomview'} resizeNotifier={this.props.resizeNotifier} justCreatedOpts={this.props.roomJustCreatedOpts} + forceTimeline={this.props.forceTimeline} />; break; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index a9e7876d90..eb60506589 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { ComponentType, createRef } from 'react'; import { createClient } from "matrix-js-sdk/src/matrix"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -176,6 +176,9 @@ interface IRoomInfo { threepid_invite?: IThreepidInvite; justCreatedOpts?: IOpts; + + // Whether or not to override default behaviour to end up at a timeline + forceTimeline?: boolean; } /* eslint-enable camelcase */ @@ -238,6 +241,7 @@ interface IState { pendingInitialSync?: boolean; justRegistered?: boolean; roomJustCreatedOpts?: IOpts; + forceTimeline?: boolean; // see props } @replaceableComponent("structures.MatrixChat") @@ -872,6 +876,15 @@ export default class MatrixChat extends React.PureComponent { params.hs_url, params.is_url, ); + // If the hs url matches then take the hs name we know locally as it is likely prettier + const defaultConfig = SdkConfig.get()["validated_server_config"] as ValidatedServerConfig; + if (defaultConfig && defaultConfig.hsUrl === newState.serverConfig.hsUrl) { + newState.serverConfig.hsName = defaultConfig.hsName; + newState.serverConfig.hsNameIsDifferent = defaultConfig.hsNameIsDifferent; + newState.serverConfig.isDefault = defaultConfig.isDefault; + newState.serverConfig.isNameResolvable = defaultConfig.isNameResolvable; + } + newState.register_client_secret = params.client_secret; newState.register_session_id = params.session_id; newState.register_id_sid = params.sid; @@ -959,6 +972,7 @@ export default class MatrixChat extends React.PureComponent { page_type: PageType.RoomView, threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, + forceTimeline: roomInfo.forceTimeline, ready: true, roomJustCreatedOpts: roomInfo.justCreatedOpts, }, () => { @@ -1587,12 +1601,16 @@ export default class MatrixChat extends React.PureComponent { if (haveNewVersion) { Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method', - import('../../async-components/views/dialogs/security/NewRecoveryMethodDialog'), + import( + '../../async-components/views/dialogs/security/NewRecoveryMethodDialog' + ) as unknown as Promise>, { newVersionInfo }, ); } else { Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed', - import('../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog'), + import( + '../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog' + ) as unknown as Promise>, ); } }); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 79aeea8321..6a204775dc 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -196,6 +196,7 @@ interface IReadReceiptForUser { @replaceableComponent("structures.MessagePanel") export default class MessagePanel extends React.Component { static contextType = RoomContext; + public context!: React.ContextType; // opaque readreceipt info for each userId; used by ReadReceiptMarker // to manage its animations @@ -560,6 +561,9 @@ export default class MessagePanel extends React.Component { } private get pendingEditItem(): string | undefined { + if (!this.props.room) { + return undefined; + } try { return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`); } catch (err) { @@ -784,6 +788,7 @@ export default class MessagePanel extends React.Component { showReadReceipts={this.props.showReadReceipts} callEventGrouper={callEventGrouper} hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble} + timelineRenderingType={this.context.timelineRenderingType} /> , ); diff --git a/src/components/structures/NotificationPanel.tsx b/src/components/structures/NotificationPanel.tsx index 52046c1855..5d2c590081 100644 --- a/src/components/structures/NotificationPanel.tsx +++ b/src/components/structures/NotificationPanel.tsx @@ -40,7 +40,7 @@ export default class NotificationPanel extends React.PureComponent { static contextType = RoomContext; render() { const emptyState = (
-

{ _t('You’re all caught up') }

+

{ _t("You're all caught up") }

{ _t('You have no visible notifications.') }

); diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 29aafd16ff..144f17ad1f 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -75,6 +75,8 @@ interface IState { groupRoomId?: string; groupId?: string; event: MatrixEvent; + initialEvent?: MatrixEvent; + initialEventHighlighted?: boolean; } @replaceableComponent("structures.RightPanel") @@ -209,6 +211,8 @@ export default class RightPanel extends React.Component { groupId: payload.groupId, member: payload.member, event: payload.event, + initialEvent: payload.initialEvent, + initialEventHighlighted: payload.highlighted, verificationRequest: payload.verificationRequest, verificationRequestPromise: payload.verificationRequestPromise, widgetId: payload.widgetId, @@ -244,7 +248,7 @@ export default class RightPanel extends React.Component { } }; - render() { + public render(): JSX.Element { let panel =
; const roomId = this.props.room ? this.props.room.roomId : undefined; @@ -327,6 +331,8 @@ export default class RightPanel extends React.Component { resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} mxEvent={this.state.event} + initialEvent={this.state.initialEvent} + initialEventHighlighted={this.state.initialEventHighlighted} permalinkCreator={this.props.permalinkCreator} e2eStatus={this.props.e2eStatus} />; break; diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 56e6b2dfb8..9c5a97578c 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -589,9 +589,12 @@ export default class RoomDirectory extends React.Component { if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32); // We use onMouseDown instead of onClick, so that we can avoid text getting selected - return [ + return
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomAvatar" > @@ -603,9 +606,8 @@ export default class RoomDirectory extends React.Component { idName={name} url={avatarUrl} /> -
, +
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomDescription" > @@ -626,30 +628,27 @@ export default class RoomDirectory extends React.Component { > { getDisplayAliasForRoom(room) }
-
, +
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomMemberCount" > { room.num_joined_members } -
, +
this.onRoomClicked(room, ev)} // cancel onMouseDown otherwise shift-clicking highlights text className="mx_RoomDirectory_preview" > { previewButton } -
, +
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_join" > { joinOrViewButton } -
, - ]; + + ; } private stringLooksLikeId(s: string, fieldType: IFieldType) { diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 9acfb7bb8e..1a1cf46023 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -32,7 +32,6 @@ import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../. interface IProps { isMinimized: boolean; - onKeyDown(ev: React.KeyboardEvent): void; /** * @returns true if a room has been selected and the search field should be cleared */ @@ -133,11 +132,6 @@ export default class RoomSearch extends React.PureComponent { this.clearInput(); defaultDispatcher.fire(Action.FocusSendMessageComposer); break; - case RoomListAction.NextRoom: - case RoomListAction.PrevRoom: - // we don't handle these actions here put pass the event on to the interested party (LeftPanel) - this.props.onKeyDown(ev); - break; case RoomListAction.SelectRoom: { const shouldClear = this.props.onSelectRoom(); if (shouldClear) { @@ -151,6 +145,10 @@ export default class RoomSearch extends React.PureComponent { } }; + public focus(): void { + this.inputRef.current?.focus(); + } + public render(): React.ReactNode { const classes = classNames({ 'mx_RoomSearch': true, diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index db7d7acd90..33fde6e509 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -92,6 +92,9 @@ import SpaceStore from "../../stores/SpaceStore"; import { logger } from "matrix-js-sdk/src/logger"; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; +import { dispatchShowThreadEvent } from '../../dispatcher/dispatch-actions/threads'; +import { fetchInitialEvent } from "../../utils/EventUtils"; +import { ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -110,6 +113,8 @@ interface IRoomProps extends MatrixClientProps { resizeNotifier: ResizeNotifier; justCreatedOpts?: IOpts; + forceTimeline?: boolean; // should we force access to the timeline, overriding (for eg) spaces + // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; } @@ -319,7 +324,7 @@ export class RoomView extends React.Component { }); }; - private onRoomViewStoreUpdate = (initial?: boolean) => { + private onRoomViewStoreUpdate = async (initial?: boolean): Promise => { if (this.unmounted) { return; } @@ -347,8 +352,6 @@ export class RoomView extends React.Component { roomLoading: RoomViewStore.isRoomLoading(), roomLoadError: RoomViewStore.getRoomLoadError(), joining: RoomViewStore.isJoining(), - initialEventId: RoomViewStore.getInitialEventId(), - isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), replyToEvent: RoomViewStore.getQuotingEvent(), // we should only peek once we have a ready client shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), @@ -360,6 +363,39 @@ export class RoomView extends React.Component { wasContextSwitch: RoomViewStore.getWasContextSwitch(), }; + const initialEventId = RoomViewStore.getInitialEventId(); + if (initialEventId) { + const room = this.context.getRoom(roomId); + let initialEvent = room?.findEventById(initialEventId); + // The event does not exist in the current sync data + // We need to fetch it to know whether to route this request + // to the main timeline or to a threaded one + // In the current state, if a thread does not exist in the sync data + // We will only display the event targeted by the `matrix.to` link + // and the root event. + // The rest will be lost for now, until the aggregation API on the server + // becomes available to fetch a whole thread + if (!initialEvent) { + initialEvent = await fetchInitialEvent( + this.context, + roomId, + initialEventId, + ); + } + + const thread = initialEvent?.getThread(); + if (thread && !initialEvent?.isThreadRoot) { + dispatchShowThreadEvent( + thread.rootEvent, + initialEvent, + RoomViewStore.isInitialEventHighlighted(), + ); + } else { + newState.initialEventId = initialEventId; + newState.isInitialEventHighlighted = RoomViewStore.isInitialEventHighlighted(); + } + } + // Add watchers for each of the settings we just looked up this.settingWatchers = this.settingWatchers.concat([ SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) => @@ -779,7 +815,10 @@ export class RoomView extends React.Component { }); break; case 'reply_to_event': - if (this.state.searchResults && payload.event.getRoomId() === this.state.roomId && !this.unmounted) { + if (this.state.searchResults + && payload.event.getRoomId() === this.state.roomId + && !this.unmounted + && payload.context === TimelineRenderingType.Room) { this.onCancelSearchClick(); } break; @@ -826,10 +865,11 @@ export class RoomView extends React.Component { } case Action.ComposerInsert: { + if (payload.composerType) break; // re-dispatch to the correct composer dis.dispatch({ ...payload, - action: this.state.editState ? "edit_composer_insert" : "send_composer_insert", + composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, }); break; } @@ -1908,7 +1948,7 @@ export class RoomView extends React.Component { ); } - if (this.state.room?.isSpaceRoom()) { + if (this.state.room?.isSpaceRoom() && !this.props.forceTimeline) { return = ({ hasPermissions, onToggleClick, onViewRoomClick, + onJoinRoomClick, numChildRooms, children, }) => { const cli = useContext(MatrixClientContext); - const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; + const [joinedRoom, setJoinedRoom] = useState(() => { + const cliRoom = cli.getRoom(room.room_id); + return cliRoom?.getMyMembership() === "join" ? cliRoom : null; + }); const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name); const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0] || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); const [showChildren, toggleShowChildren] = useStateToggle(true); const [onFocus, isActive, ref] = useRovingTabIndex(); + const [busy, setBusy] = useState(false); const onPreviewClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); - onViewRoomClick(false, room.room_type as RoomType); + onViewRoomClick(); }; - const onJoinClick = (ev: ButtonEvent) => { + const onJoinClick = async (ev: ButtonEvent) => { + setBusy(true); ev.preventDefault(); ev.stopPropagation(); - onViewRoomClick(true, room.room_type as RoomType); + onJoinRoomClick(); + setJoinedRoom(await awaitRoomDownSync(cli, room.room_id)); + setBusy(false); }; let button; - if (joinedRoom) { + if (busy) { + button = + + ; + } else if (joinedRoom) { button = = ({ description += " · " + topic; } + let joinedSection; + if (joinedRoom) { + joinedSection =
+ { _t("Joined") } +
; + } + let suggestedSection; - if (suggested) { + if (suggested && (!joinedRoom || hasPermissions)) { suggestedSection = { _t("Suggested") } ; @@ -183,6 +207,7 @@ const Tile: React.FC = ({ { avatar }
{ name } + { joinedSection } { suggestedSection }
@@ -274,6 +299,7 @@ const Tile: React.FC = ({ = ({ ; }; -export const showRoom = ( - cli: MatrixClient, - hierarchy: RoomHierarchy, - roomId: string, - autoJoin = false, - roomType?: RoomType, -) => { +export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void => { const room = hierarchy.roomMap.get(roomId); // Don't let the user view a room they won't be able to either peek or join: @@ -309,7 +329,6 @@ export const showRoom = ( const roomAlias = getDisplayAliasForRoom(room) || undefined; dis.dispatch({ action: "view_room", - auto_join: autoJoin, should_peek: true, _type: "room_directory", // instrumentation room_alias: roomAlias, @@ -324,13 +343,29 @@ export const showRoom = ( }); }; +export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void => { + // Don't let the user view a room they won't be able to either peek or join: + // fail earlier so they don't have to click back to the directory. + if (cli.isGuest()) { + dis.dispatch({ action: "require_registration" }); + return; + } + + cli.joinRoom(roomId, { + viaServers: Array.from(hierarchy.viaMap.get(roomId) || []), + }).catch(err => { + RoomViewStore.showJoinRoomError(err, roomId); + }); +}; + interface IHierarchyLevelProps { root: IHierarchyRoom; roomSet: Set; hierarchy: RoomHierarchy; parents: Set; selectedMap?: Map>; - onViewRoomClick(roomId: string, autoJoin: boolean, roomType?: RoomType): void; + onViewRoomClick(roomId: string, roomType?: RoomType): void; + onJoinRoomClick(roomId: string): void; onToggleClick?(parentId: string, childId: string): void; } @@ -365,6 +400,7 @@ export const HierarchyLevel = ({ parents, selectedMap, onViewRoomClick, + onJoinRoomClick, onToggleClick, }: IHierarchyLevelProps) => { const cli = useContext(MatrixClientContext); @@ -392,9 +428,8 @@ export const HierarchyLevel = ({ room={room} suggested={hierarchy.isSuggested(root.room_id, room.room_id)} selected={selectedMap?.get(root.room_id)?.has(room.room_id)} - onViewRoomClick={(autoJoin, roomType) => { - onViewRoomClick(room.room_id, autoJoin, roomType); - }} + onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)} + onJoinRoomClick={() => onJoinRoomClick(room.room_id)} hasPermissions={hasPermissions} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} /> @@ -412,9 +447,8 @@ export const HierarchyLevel = ({ }).length} suggested={hierarchy.isSuggested(root.room_id, space.room_id)} selected={selectedMap?.get(root.room_id)?.has(space.room_id)} - onViewRoomClick={(autoJoin, roomType) => { - onViewRoomClick(space.room_id, autoJoin, roomType); - }} + onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)} + onJoinRoomClick={() => onJoinRoomClick(space.room_id)} hasPermissions={hasPermissions} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} > @@ -425,6 +459,7 @@ export const HierarchyLevel = ({ parents={newParents} selectedMap={selectedMap} onViewRoomClick={onViewRoomClick} + onJoinRoomClick={onJoinRoomClick} onToggleClick={onToggleClick} /> @@ -537,9 +572,19 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu onClick={async () => { setRemoving(true); try { + const userId = cli.getUserId(); for (const [parentId, childId] of selectedRelations) { await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); + // remove the child->parent relation too, if we have permission to. + const childRoom = cli.getRoom(childId); + const parentRelation = childRoom?.currentState.getStateEvents(EventType.SpaceParent, parentId); + if (childRoom?.currentState.maySendStateEvent(EventType.SpaceParent, userId) && + Array.isArray(parentRelation?.getContent().via) + ) { + await cli.sendStateEvent(childId, EventType.SpaceParent, {}, parentId); + } + hierarchy.removeRelation(parentId, childId); } } catch (e) { @@ -678,9 +723,8 @@ const SpaceHierarchy = ({ parents={new Set()} selectedMap={selected} onToggleClick={hasPermissions ? onToggleClick : undefined} - onViewRoomClick={(roomId, autoJoin, roomType) => { - showRoom(cli, hierarchy, roomId, autoJoin, roomType); - }} + onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)} + onJoinRoomClick={(roomId) => joinRoom(cli, hierarchy, roomId)} /> ; } else if (!hierarchy.canLoadMore) { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index dd12f76aac..25128dd4f0 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -55,7 +55,7 @@ import { showSpaceInvite, showSpaceSettings, } from "../../utils/space"; -import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; +import SpaceHierarchy, { joinRoom, showRoom } from "./SpaceHierarchy"; import MemberAvatar from "../views/avatars/MemberAvatar"; import SpaceStore from "../../stores/SpaceStore"; import FacePile from "../views/elements/FacePile"; @@ -127,8 +127,17 @@ const useMyRoomMembership = (room: Room) => { return membership; }; -const SpaceInfo = ({ space }) => { +const SpaceInfo = ({ space }: { space: Room }) => { + const summary = useAsyncMemo(async () => { + if (space.getMyMembership() !== "invite") return; + try { + return space.client.getRoomSummary(space.roomId); + } catch (e) { + return null; + } + }, [space]); const joinRule = useRoomState(space, state => state.getJoinRule()); + const membership = useMyRoomMembership(space); let visibilitySection; if (joinRule === "public") { @@ -141,12 +150,18 @@ const SpaceInfo = ({ space }) => { ; } - return
- { visibilitySection } - { joinRule === "public" && + let memberSection; + if (membership === "invite" && summary) { + // Don't trust local state and instead use the summary API + memberSection = + { _t("%(count)s members", { count: summary.num_joined_members }) } + ; + } else if (summary === null) { + memberSection = { (count) => count > 0 ? ( { defaultDispatcher.dispatch({ action: Action.SetRightPanelPhase, @@ -158,7 +173,12 @@ const SpaceInfo = ({ space }) => { { _t("%(count)s members", { count }) } ) : null } - } + ; + } + + return
+ { visibilitySection } + { memberSection }
; }; @@ -488,7 +508,7 @@ const SpaceLanding = ({ space }: { space: Room }) => { ) } - +
; }; @@ -648,10 +668,6 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {

{ _t("Me and my teammates") }

{ _t("A private space for you and your teammates") }
-
-

{ _t("Teammates might not be able to view or join any private rooms you make.") }

-

{ _t("We're working on this, but just want to let you know.") }

-
; }; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 40e9479251..c7a87945dd 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import { Room } from 'matrix-js-sdk/src/models/room'; @@ -24,7 +23,6 @@ import BaseCard from "../views/right_panel/BaseCard"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import ResizeNotifier from '../../utils/ResizeNotifier'; -import EventTile, { TileShape } from '../views/rooms/EventTile'; import MatrixClientContext from '../../contexts/MatrixClientContext'; import { _t } from '../../languageHandler'; import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton'; @@ -34,6 +32,7 @@ import TimelinePanel from './TimelinePanel'; import { Layout } from '../../settings/Layout'; import { useEventEmitter } from '../../hooks/useEventEmitter'; import AccessibleButton from '../views/elements/AccessibleButton'; +import { TileShape } from '../views/rooms/EventTile'; interface IProps { roomId: string; @@ -41,18 +40,6 @@ interface IProps { resizeNotifier: ResizeNotifier; } -export const ThreadPanelItem: React.FC<{ event: MatrixEvent }> = ({ event }) => { - return ; -}; - export enum ThreadFilterType { "My", "All" @@ -77,10 +64,11 @@ const useFilteredThreadsTimelinePanel = ({ filterOption: ThreadFilterType; updateTimeline: () => void; }) => { - const timelineSet = useMemo(() => new EventTimelineSet(room, { - unstableClientRelationAggregation: true, + const timelineSet = useMemo(() => new EventTimelineSet(null, { timelineSupport: true, - }), [room]); + unstableClientRelationAggregation: true, + pendingEvents: false, + }), []); useEffect(() => { let filteredThreads = Array.from(threads); @@ -93,7 +81,7 @@ const useFilteredThreadsTimelinePanel = ({ // The proper list order should be top-to-bottom, like in social-media newsfeeds. filteredThreads.reverse().forEach(([id, thread]) => { const event = thread.rootEvent; - if (timelineSet.findEventById(event.getId()) || event.status !== null) return; + if (!event || timelineSet.findEventById(event.getId()) || event.status !== null) return; timelineSet.addEventToTimeline( event, timelineSet.getLiveTimeline(), @@ -153,7 +141,7 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption }: { const options: readonly ThreadPanelHeaderOption[] = [ { label: _t("My threads"), - description: _t("Shows all threads you’ve participated in"), + description: _t("Shows all threads you've participated in"), key: ThreadFilterType.My, }, { diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 7bd6415cd3..17895be9d1 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -37,6 +37,15 @@ import { MatrixClientPeg } from '../../MatrixClientPeg'; import { E2EStatus } from '../../utils/ShieldUtils'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext'; +import { ChevronFace, ContextMenuTooltipButton } from './ContextMenu'; +import { _t } from '../../languageHandler'; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from '../views/context_menus/IconizedContextMenu'; +import { ButtonEvent } from '../views/elements/AccessibleButton'; +import { copyPlaintext } from '../../utils/strings'; +import { sleep } from 'matrix-js-sdk/src/utils'; interface IProps { room: Room; @@ -45,13 +54,31 @@ interface IProps { mxEvent: MatrixEvent; permalinkCreator?: RoomPermalinkCreator; e2eStatus?: E2EStatus; + initialEvent?: MatrixEvent; + initialEventHighlighted?: boolean; } - interface IState { thread?: Thread; editState?: EditorStateTransfer; + replyToEvent?: MatrixEvent; + threadOptionsPosition: DOMRect | null; + copyingPhase: CopyingPhase; } +enum CopyingPhase { + Idle, + Copying, + Failed, +} + +const contextMenuBelow = (elementRect: DOMRect) => { + // align the context menu's icons with the icon which opened the context menu + const left = elementRect.left + window.pageXOffset + elementRect.width; + const top = elementRect.bottom + window.pageYOffset + 17; + const chevronFace = ChevronFace.None; + return { left, top, chevronFace }; +}; + @replaceableComponent("structures.ThreadView") export default class ThreadView extends React.Component { static contextType = RoomContext; @@ -61,7 +88,10 @@ export default class ThreadView extends React.Component { constructor(props: IProps) { super(props); - this.state = {}; + this.state = { + threadOptionsPosition: null, + copyingPhase: CopyingPhase.Idle, + }; } public componentDidMount(): void { @@ -101,19 +131,26 @@ export default class ThreadView extends React.Component { } } switch (payload.action) { - case Action.EditEvent: { + case Action.EditEvent: // Quit early if it's not a thread context if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return; // Quit early if that's not a thread event if (payload.event && !payload.event.getThread()) return; - const editState = payload.event ? new EditorStateTransfer(payload.event) : null; - this.setState({ editState }, () => { + this.setState({ + editState: payload.event ? new EditorStateTransfer(payload.event) : null, + }, () => { if (payload.event) { this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId()); } }); break; - } + case 'reply_to_event': + if (payload.context === TimelineRenderingType.Thread) { + this.setState({ + replyToEvent: payload.event, + }); + } + break; default: break; } @@ -123,7 +160,11 @@ export default class ThreadView extends React.Component { let thread = mxEv.getThread(); if (!thread) { const client = MatrixClientPeg.get(); - thread = new Thread([mxEv], this.props.room, client); + thread = new Thread( + [mxEv], + this.props.room, + client, + ); mxEv.setThread(thread); } thread.on(ThreadEvent.Update, this.updateThread); @@ -155,7 +196,114 @@ export default class ThreadView extends React.Component { this.timelinePanelRef.current?.refreshTimeline(); }; + private onScroll = (): void => { + if (this.props.initialEvent && this.props.initialEventHighlighted) { + dis.dispatch({ + action: 'view_room', + room_id: this.props.room.roomId, + event_id: this.props.initialEvent?.getId(), + highlighted: false, + replyingToEvent: this.state.replyToEvent, + }); + } + }; + + private onThreadOptionsClick = (ev: ButtonEvent): void => { + if (this.isThreadOptionsVisible) { + this.closeThreadOptions(); + } else { + const position = ev.currentTarget.getBoundingClientRect(); + this.setState({ + threadOptionsPosition: position, + }); + } + }; + + private closeThreadOptions = (): void => { + this.setState({ + threadOptionsPosition: null, + }); + }; + + private get isThreadOptionsVisible(): boolean { + return !!this.state.threadOptionsPosition; + } + + private viewInRoom = (evt: ButtonEvent): void => { + evt.preventDefault(); + evt.stopPropagation(); + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + highlighted: true, + room_id: this.props.mxEvent.getRoomId(), + }); + this.closeThreadOptions(); + }; + + private copyLinkToThread = async (evt: ButtonEvent): Promise => { + evt.preventDefault(); + evt.stopPropagation(); + + const matrixToUrl = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + + this.setState({ + copyingPhase: CopyingPhase.Copying, + }); + + const hasSuccessfullyCopied = await copyPlaintext(matrixToUrl); + + if (hasSuccessfullyCopied) { + await sleep(500); + } else { + this.setState({ copyingPhase: CopyingPhase.Failed }); + await sleep(2500); + } + + this.setState({ copyingPhase: CopyingPhase.Idle }); + + if (hasSuccessfullyCopied) { + this.closeThreadOptions(); + } + }; + + private renderThreadViewHeader = (): JSX.Element => { + return
+ { _t("Thread") } + + { this.isThreadOptionsVisible && ( + + this.viewInRoom(e)} + label={_t("View in room")} + iconClassName="mx_ThreadPanel_viewInRoom" + /> + this.copyLinkToThread(e)} + label={_t("Copy link to thread")} + iconClassName="mx_ThreadPanel_copyLinkToThread" + /> + + ) } + +
; + }; + public render(): JSX.Element { + const highlightedEventId = this.props.initialEventHighlighted + ? this.props.initialEvent?.getId() + : null; return ( { }}> { this.state.thread && ( { showUrlPreview={true} tileShape={TileShape.Thread} empty={
empty
} - alwaysShowTimestamps={true} layout={Layout.Group} hideThreadedMessages={false} hidden={false} @@ -189,6 +337,9 @@ export default class ThreadView extends React.Component { permalinkCreator={this.props.permalinkCreator} membersLoaded={true} editState={this.state.editState} + eventId={this.props.initialEvent?.getId()} + highlightedEventId={highlightedEventId} + onUserScroll={this.onScroll} /> ) } @@ -199,7 +350,7 @@ export default class ThreadView extends React.Component { rel_type: RelationType.Thread, event_id: this.state.thread.id, }} - showReplyPreview={false} + replyToEvent={this.state.replyToEvent} permalinkCreator={this.props.permalinkCreator} e2eStatus={this.props.e2eStatus} compact={true} diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 646fc32b59..4a533f1f8e 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -32,7 +32,7 @@ import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; -import { getCustomTheme } from "../../theme"; +import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; @@ -69,6 +69,7 @@ type PartialDOMRect = Pick; interface IState { contextMenuPosition: PartialDOMRect; isDarkTheme: boolean; + isHighContrast: boolean; selectedSpace?: Room; pendingRoomJoin: Set; } @@ -87,6 +88,7 @@ export default class UserMenu extends React.Component { this.state = { contextMenuPosition: null, isDarkTheme: this.isUserOnDarkTheme(), + isHighContrast: this.isUserOnHighContrastTheme(), pendingRoomJoin: new Set(), }; @@ -142,6 +144,18 @@ export default class UserMenu extends React.Component { } } + private isUserOnHighContrastTheme(): boolean { + if (SettingsStore.getValue("use_system_theme")) { + return window.matchMedia("(prefers-contrast: more)").matches; + } else { + const theme = SettingsStore.getValue("theme"); + if (theme.startsWith("custom-")) { + return false; + } + return isHighContrastTheme(theme); + } + } + private onProfileUpdate = async () => { // the store triggered an update, so force a layout update. We don't // have any state to store here for that to magically happen. @@ -153,7 +167,11 @@ export default class UserMenu extends React.Component { }; private onThemeChanged = () => { - this.setState({ isDarkTheme: this.isUserOnDarkTheme() }); + this.setState( + { + isDarkTheme: this.isUserOnDarkTheme(), + isHighContrast: this.isUserOnHighContrastTheme(), + }); }; private onAction = (ev: ActionPayload) => { @@ -221,7 +239,13 @@ export default class UserMenu extends React.Component { // Disable system theme matching if the user hits this button SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); - const newTheme = this.state.isDarkTheme ? "light" : "dark"; + let newTheme = this.state.isDarkTheme ? "light" : "dark"; + if (this.state.isHighContrast) { + const hcTheme = findHighContrastTheme(newTheme); + if (hcTheme) { + newTheme = hcTheme; + } + } SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab }; diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 2cb2a2a1a9..66ade9e6ed 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -26,13 +26,12 @@ import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; import CountlyAnalytics from "../../../CountlyAnalytics"; import ServerPicker from "../../views/elements/ServerPicker"; +import EmailField from "../../views/auth/EmailField"; import PassphraseField from '../../views/auth/PassphraseField'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; - import { IValidationResult } from "../../views/elements/Validation"; import InlineSpinner from '../../views/elements/InlineSpinner'; - import { logger } from "matrix-js-sdk/src/logger"; enum Phase { @@ -68,6 +67,7 @@ interface IState { serverErrorIsFatal: boolean; serverDeadError: string; + emailFieldValid: boolean; passwordFieldValid: boolean; currentHttpRequest?: Promise; } @@ -90,6 +90,7 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", + emailFieldValid: false, passwordFieldValid: false, }; @@ -169,10 +170,13 @@ export default class ForgotPassword extends React.Component { // refresh the server errors, just in case the server came back online await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig)); + await this['email_field'].validate({ allowEmpty: false }); await this['password_field'].validate({ allowEmpty: false }); if (!this.state.email) { this.showErrorDialog(_t('The email address linked to your account must be entered.')); + } else if (!this.state.emailFieldValid) { + this.showErrorDialog(_t("The email address doesn't appear to be valid.")); } else if (!this.state.password || !this.state.password2) { this.showErrorDialog(_t('A new password must be entered.')); } else if (!this.state.passwordFieldValid) { @@ -222,6 +226,12 @@ export default class ForgotPassword extends React.Component { }); } + private onEmailValidate = (result: IValidationResult) => { + this.setState({ + emailFieldValid: result.valid, + }); + }; + private onPasswordValidate(result: IValidationResult) { this.setState({ passwordFieldValid: result.valid, @@ -271,13 +281,13 @@ export default class ForgotPassword extends React.Component { />
- this['email_field'] = field} + autoFocus={true} onChange={this.onInputChanged.bind(this, "email")} - autoFocus + onValidate={this.onEmailValidate} onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")} /> diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 3c66a1ab86..ab1956dd6a 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -272,7 +272,7 @@ export default class Registration extends React.Component { private onUIAuthFinished = async (success: boolean, response: any) => { if (!success) { - let msg = response.message || response.toString(); + let errorText = response.message || response.toString(); // can we give a better error message? if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( @@ -291,7 +291,7 @@ export default class Registration extends React.Component { '': _td("Please contact your service administrator to continue using this service."), }, ); - msg =
+ errorText =

{ errorTop }

{ errorDetail }

; @@ -301,15 +301,18 @@ export default class Registration extends React.Component { msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn'); } if (!msisdnAvailable) { - msg = _t('This server does not support authentication with a phone number.'); + errorText = _t('This server does not support authentication with a phone number.'); } } else if (response.errcode === "M_USER_IN_USE") { - msg = _t("That username already exists, please try another."); + errorText = _t("That username already exists, please try another."); + } else if (response.errcode === "M_THREEPID_IN_USE") { + errorText = _t("That e-mail address is already in use."); } + this.setState({ busy: false, doingUIAuth: false, - errorText: msg, + errorText, }); return; } diff --git a/src/components/structures/auth/SetupEncryptionBody.tsx b/src/components/structures/auth/SetupEncryptionBody.tsx index e2b1aebcfd..19e530aaaa 100644 --- a/src/components/structures/auth/SetupEncryptionBody.tsx +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -39,7 +39,7 @@ function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean { } interface IProps { - onFinished: (boolean) => void; + onFinished: () => void; } interface IState { @@ -70,7 +70,7 @@ export default class SetupEncryptionBody extends React.Component private onStoreUpdate = () => { const store = SetupEncryptionStore.sharedInstance(); if (store.phase === Phase.Finished) { - this.props.onFinished(true); + this.props.onFinished(); return; } this.setState({ @@ -97,13 +97,16 @@ export default class SetupEncryptionBody extends React.Component const userId = cli.getUserId(); const requestPromise = cli.requestVerification(userId); - this.props.onFinished(true); + // We need to call onFinished now to close this dialog, and + // again later to signal that the verification is complete. + this.props.onFinished(); Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, { verificationRequestPromise: requestPromise, member: cli.getUser(userId), onFinished: async () => { const request = await requestPromise; request.cancel(); + this.props.onFinished(); }, }); }; @@ -125,6 +128,7 @@ export default class SetupEncryptionBody extends React.Component }; private onResetConfirmClick = () => { + this.props.onFinished(); const store = SetupEncryptionStore.sharedInstance(); store.resetConfirm(); }; @@ -140,7 +144,7 @@ export default class SetupEncryptionBody extends React.Component }; private onEncryptionPanelClose = () => { - this.props.onFinished(false); + this.props.onFinished(); }; public render() { @@ -249,7 +253,7 @@ export default class SetupEncryptionBody extends React.Component return (

{ _t( - "Without verifying, you won’t have access to all your messages " + + "Without verifying, you won't have access to all your messages " + "and may appear as untrusted to others.", ) }

diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index db93d30c27..5e03aca912 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -215,7 +215,7 @@ export default class SoftLogout extends React.Component { if (this.state.keyBackupNeeded) { introText = _t( "Regain access to your account and recover encryption keys stored in this session. " + - "Without them, you won’t be able to read all of your secure messages in any session."); + "Without them, you won't be able to read all of your secure messages in any session."); } if (this.state.loginView === LOGIN_VIEW.PASSWORD) { diff --git a/src/components/views/auth/EmailField.tsx b/src/components/views/auth/EmailField.tsx new file mode 100644 index 0000000000..3ff1700030 --- /dev/null +++ b/src/components/views/auth/EmailField.tsx @@ -0,0 +1,92 @@ +/* +Copyright 2021 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, { PureComponent, RefCallback, RefObject } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Field, { IInputProps } from "../elements/Field"; +import { _t, _td } from "../../../languageHandler"; +import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; +import * as Email from "../../../email"; + +interface IProps extends Omit { + id?: string; + fieldRef?: RefCallback | RefObject; + value: string; + autoFocus?: boolean; + + label?: string; + labelRequired?: string; + labelInvalid?: string; + + // When present, completely overrides the default validation rules. + validationRules?: (fieldState: IFieldState) => Promise; + + onChange(ev: React.FormEvent): void; + onValidate?(result: IValidationResult): void; +} + +@replaceableComponent("views.auth.EmailField") +class EmailField extends PureComponent { + static defaultProps = { + label: _td("Email"), + labelRequired: _td("Enter email address"), + labelInvalid: _td("Doesn't look like a valid email address"), + }; + + public readonly validate = withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t(this.props.labelRequired), + }, + { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t(this.props.labelInvalid), + }, + ], + }); + + onValidate = async (fieldState: IFieldState) => { + let validate = this.validate; + if (this.props.validationRules) { + validate = this.props.validationRules; + } + + const result = await validate(fieldState); + if (this.props.onValidate) { + this.props.onValidate(result); + } + + return result; + }; + + render() { + return ; + } +} + +export default EmailField; diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 587d7f2453..920cec4e5f 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -22,11 +22,11 @@ import SdkConfig from '../../../SdkConfig'; import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import AccessibleButton from "../elements/AccessibleButton"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import withValidation from "../elements/Validation"; -import * as Email from "../../../email"; +import withValidation, { IValidationResult } from "../elements/Validation"; import Field from "../elements/Field"; import CountryDropdown from "./CountryDropdown"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import EmailField from "./EmailField"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -262,26 +262,8 @@ export default class PasswordLogin extends React.PureComponent { return result; }; - private validateEmailRules = withValidation({ - rules: [ - { - key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !!value; - }, - invalid: () => _t("Enter email address"), - }, { - key: "email", - test: ({ value }) => !value || Email.looksValid(value), - invalid: () => _t("Doesn't look like a valid email address"), - }, - ], - }); - - private onEmailValidate = async (fieldState) => { - const result = await this.validateEmailRules(fieldState); + private onEmailValidate = (result: IValidationResult) => { this.markFieldValid(LoginField.Email, result.valid); - return result; }; private validatePhoneNumberRules = withValidation({ @@ -332,12 +314,10 @@ export default class PasswordLogin extends React.PureComponent { switch (loginType) { case LoginField.Email: classes.error = this.props.loginIncorrect && !this.props.username; - return { disabled={this.props.disableSubmit} autoFocus={autoFocus} onValidate={this.onEmailValidate} - ref={field => this[LoginField.Email] = field} + fieldRef={field => this[LoginField.Email] = field} />; case LoginField.MatrixId: classes.error = this.props.loginIncorrect && !this.props.username; diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index c66d6b80fd..24e73f2992 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -23,8 +23,9 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import { SAFE_LOCALPART_REGEX } from '../../../Registration'; -import withValidation from '../elements/Validation'; +import withValidation, { IValidationResult } from '../elements/Validation'; import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; +import EmailField from "./EmailField"; import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; import Field from '../elements/Field'; @@ -253,10 +254,8 @@ export default class RegistrationForm extends React.PureComponent { - const result = await this.validateEmailRules(fieldState); + private onEmailValidate = (result: IValidationResult) => { this.markFieldValid(RegistrationField.Email, result.valid); - return result; }; private validateEmailRules = withValidation({ @@ -426,14 +425,14 @@ export default class RegistrationForm extends React.PureComponent this[RegistrationField.Email] = field} - type="text" - label={emailPlaceholder} + return this[RegistrationField.Email] = field} + label={emailLabel} value={this.state.email} + validationRules={this.validateEmailRules.bind(this)} onChange={this.onEmailChange} onValidate={this.onEmailValidate} onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")} diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 6aaef29854..92d46c3132 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -150,6 +150,7 @@ const BaseAvatar = (props: IProps) => { return ( { }; } - // TODO: type when js-sdk has types - private onRoomStateEvents = (ev: any) => { + private onRoomStateEvents = (ev: MatrixEvent) => { if (!this.props.room || ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'm.room.avatar' diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index 28c35eef8f..ec626df674 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -40,6 +40,7 @@ import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRight import { Action } from "../../../dispatcher/actions"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; import { BetaPill } from "../beta/BetaCard"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps extends IContextMenuProps { space: Room; @@ -105,6 +106,29 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => { ; } + let devtoolsSection; + if (SettingsStore.getValue("developerMode")) { + const onViewTimelineClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({ + action: 'view_room', + room_id: space.roomId, + forceTimeline: true, + }); + onFinished(); + }; + + devtoolsSection = + + ; + } + const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); let newRoomSection; @@ -209,6 +233,7 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => { { newRoomSection } { leaveSection } + { devtoolsSection } ; }; diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 0da5f189bf..c61d638204 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -284,7 +284,7 @@ export default class CreateRoomDialog extends React.Component { let microcopy; if (privateShouldBeEncrypted()) { if (this.state.canChangeEncryption) { - microcopy = _t("You can’t disable this later. Bridges & most bots won’t work yet."); + microcopy = _t("You can't disable this later. Bridges & most bots won't work yet."); } else { microcopy = _t("Your server requires encryption to be enabled in private rooms."); } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index fe3968673c..d37b8c7d6b 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1412,7 +1412,7 @@ export default class InviteDialog extends React.PureComponent { _t("Some suggestions may be hidden for privacy.") } -

{ _t("If you can't see who you’re looking for, send them your invite link below.") }

+

{ _t("If you can't see who you're looking for, send them your invite link below.") }

; const link = makeUserPermalink(MatrixClientPeg.get().getUserId()); footer =
diff --git a/src/components/views/dialogs/LogoutDialog.tsx b/src/components/views/dialogs/LogoutDialog.tsx index 5ac3141269..759952a048 100644 --- a/src/components/views/dialogs/LogoutDialog.tsx +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import Modal from '../../../Modal'; import * as sdk from '../../../index'; @@ -85,7 +85,9 @@ export default class LogoutDialog extends React.Component { private onExportE2eKeysClicked = (): void => { Modal.createTrackedDialogAsync('Export E2E Keys', '', - import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), + import( + '../../../async-components/views/dialogs/security/ExportE2eKeysDialog' + ) as unknown as Promise>, { matrixClient: MatrixClientPeg.get(), }, @@ -111,7 +113,9 @@ export default class LogoutDialog extends React.Component { ); } else { Modal.createTrackedDialogAsync("Key Backup", "Key Backup", - import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog"), + import( + "../../../async-components/views/dialogs/security/CreateKeyBackupDialog" + ) as unknown as Promise>, null, null, /* priority = */ false, /* static = */ true, ); } diff --git a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx index 804a1aec35..8e406c9dc8 100644 --- a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx +++ b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx @@ -21,25 +21,14 @@ import { IDialogProps } from "./IDialogProps"; import { useRef, useState } from "react"; import Field from "../elements/Field"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import withValidation from "../elements/Validation"; -import * as Email from "../../../email"; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; +import EmailField from "../auth/EmailField"; interface IProps extends IDialogProps { onFinished(continued: boolean, email?: string): void; } -const validation = withValidation({ - rules: [ - { - key: "email", - test: ({ value }) => !value || Email.looksValid(value), - invalid: () => _t("Doesn't look like a valid email address"), - }, - ], -}); - const RegistrationEmailPromptDialog: React.FC = ({ onFinished }) => { const [email, setEmail] = useState(""); const fieldRef = useRef(); @@ -47,11 +36,11 @@ const RegistrationEmailPromptDialog: React.FC = ({ onFinished }) => { const onSubmit = async (e) => { e.preventDefault(); if (email) { - const valid = await fieldRef.current.validate({ allowEmpty: false }); + const valid = await fieldRef.current.validate({}); if (!valid) { fieldRef.current.focus(); - fieldRef.current.validate({ allowEmpty: false, focused: true }); + fieldRef.current.validate({ focused: true }); return; } } @@ -72,16 +61,15 @@ const RegistrationEmailPromptDialog: React.FC = ({ onFinished }) => { b: sub => { sub }, }) }

- { - setEmail(ev.target.value); + const target = ev.target as HTMLInputElement; + setEmail(target.value); }} - onValidate={async fieldState => await validation(fieldState)} onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email2_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email2_blur")} /> diff --git a/src/components/views/dialogs/ScrollableBaseModal.tsx b/src/components/views/dialogs/ScrollableBaseModal.tsx new file mode 100644 index 0000000000..82cd392c4f --- /dev/null +++ b/src/components/views/dialogs/ScrollableBaseModal.tsx @@ -0,0 +1,116 @@ +/* +Copyright 2021 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, { FormEvent } from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { Key } from "../../../Keyboard"; +import { IDialogProps } from "./IDialogProps"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import FocusLock from "react-focus-lock"; +import { _t } from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; + +export interface IScrollableBaseState { + canSubmit: boolean; + title: string; + actionLabel: string; +} + +/** + * Scrollable dialog base from Compound (Web Components). + */ +export default abstract class ScrollableBaseModal + extends React.PureComponent { + protected constructor(props: TProps) { + super(props); + } + + protected get matrixClient(): MatrixClient { + return MatrixClientPeg.get(); + } + + private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => { + if (e.key === Key.ESCAPE) { + e.stopPropagation(); + e.preventDefault(); + this.cancel(); + } + }; + + private onCancel = () => { + this.cancel(); + }; + + private onSubmit = (e: MouseEvent | FormEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (!this.state.canSubmit) return; // pretend the submit button was disabled + this.submit(); + }; + + protected abstract cancel(): void; + protected abstract submit(): void; + protected abstract renderContent(): React.ReactNode; + + public render(): JSX.Element { + return ( + + +
+

{ this.state.title }

+ +
+ +
+ { this.renderContent() } +
+
+ + { _t("Cancel") } + + + { this.state.actionLabel } + +
+ +
+
+ ); + } +} diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 4c355c6c0e..585e49ccdd 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -167,7 +167,7 @@ export default class ServerPickerDialog extends React.PureComponent

- { _t("We call the places where you can host your account ‘homeservers’.") } { text } + { _t("We call the places where you can host your account 'homeservers'.") } { text }

{ private sendBugReport = (): void => { - Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {}); + Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, { + error: this.props.error, + }); }; private onClearStorageClick = (): void => { diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 5715e504d4..13603bb3c1 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -346,15 +346,15 @@ export default class AccessSecretStorageDialog extends React.PureComponent - { keyStatus } : null; + />; return ( { this.persistKey = getPersistKey(this.props.app.id); try { this.sgWidget = new StopGapWidget(this.props); - this.sgWidget.on("preparing", this.onWidgetPrepared); + this.sgWidget.on("preparing", this.onWidgetPreparing); this.sgWidget.on("ready", this.onWidgetReady); + // emits when the capabilites have been setup or changed + this.sgWidget.on("capabilitiesNotified", this.onWidgetCapabilitiesNotified); } catch (e) { logger.log("Failed to construct widget", e); this.sgWidget = null; @@ -155,6 +158,10 @@ export default class AppTile extends React.Component { error: null, menuDisplayed: false, widgetPageTitle: this.props.widgetPageTitle, + // requiresClient is initially set to true. This avoids the broken state of the popout + // button being visible (for an instance) and then disappearing when the widget is loaded. + // requiresClient <-> hide the popout button + requiresClient: true, }; } @@ -216,7 +223,7 @@ export default class AppTile extends React.Component { } try { this.sgWidget = new StopGapWidget(newProps); - this.sgWidget.on("preparing", this.onWidgetPrepared); + this.sgWidget.on("preparing", this.onWidgetPreparing); this.sgWidget.on("ready", this.onWidgetReady); this.startWidget(); } catch (e) { @@ -287,7 +294,7 @@ export default class AppTile extends React.Component { if (this.sgWidget) this.sgWidget.stop({ forceDestroy: true }); } - private onWidgetPrepared = (): void => { + private onWidgetPreparing = (): void => { this.setState({ loading: false }); }; @@ -297,6 +304,12 @@ export default class AppTile extends React.Component { } }; + private onWidgetCapabilitiesNotified = (): void => { + this.setState({ + requiresClient: this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.RequiresClient), + }); + }; + private onAction = (payload): void => { if (payload.widgetId === this.props.app.id) { switch (payload.action) { @@ -512,7 +525,7 @@ export default class AppTile extends React.Component { { this.props.showTitle && this.getTileTitle() } - { this.props.showPopout && { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { element, prefixComponent, postfixComponent, className, onValidate, children, tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus, + usePlaceholderAsHint, ...inputProps } = this.props; // Set some defaults for the element @@ -256,7 +260,8 @@ export default class Field extends React.PureComponent { // If we have a prefix element, leave the label always at the top left and // don't animate it, as it looks a bit clunky and would add complexity to do // properly. - mx_Field_labelAlwaysTopLeft: prefixComponent, + mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint, + mx_Field_placeholderIsHint: usePlaceholderAsHint, mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true, mx_Field_invalid: hasValidationFlag ? !forceValidity diff --git a/src/components/views/elements/PersistentApp.tsx b/src/components/views/elements/PersistentApp.tsx index 8d0751cc1d..d80a00584c 100644 --- a/src/components/views/elements/PersistentApp.tsx +++ b/src/components/views/elements/PersistentApp.tsx @@ -25,17 +25,21 @@ import { EventSubscription } from 'fbemitter'; import AppTile from "./AppTile"; import { Room } from "matrix-js-sdk/src/models/room"; +interface IProps { + // none +} + interface IState { roomId: string; persistentWidgetId: string; } @replaceableComponent("views.elements.PersistentApp") -export default class PersistentApp extends React.Component<{}, IState> { +export default class PersistentApp extends React.Component { private roomStoreToken: EventSubscription; - constructor() { - super({}); + constructor(props: IProps) { + super(props); this.state = { roomId: RoomViewStore.getRoomId(), diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx new file mode 100644 index 0000000000..8869286afa --- /dev/null +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -0,0 +1,144 @@ +/* +Copyright 2021 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 ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; +import { IDialogProps } from "../dialogs/IDialogProps"; +import React, { ChangeEvent, createRef } from "react"; +import { _t } from "../../../languageHandler"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { arrayFastClone, arraySeed } from "../../../utils/arrays"; +import Field from "./Field"; +import AccessibleButton from "./AccessibleButton"; +import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "../../../polls/consts"; + +interface IProps extends IDialogProps { + room: Room; +} + +interface IState extends IScrollableBaseState { + question: string; + options: string[]; + busy: boolean; +} + +const MIN_OPTIONS = 2; +const MAX_OPTIONS = 20; +const DEFAULT_NUM_OPTIONS = 2; + +export default class PollCreateDialog extends ScrollableBaseModal { + private addOptionRef = createRef(); + + public constructor(props: IProps) { + super(props); + + this.state = { + title: _t("Create poll"), + actionLabel: _t("Create Poll"), + canSubmit: false, // need to add a question and at least one option first + + question: "", + options: arraySeed("", DEFAULT_NUM_OPTIONS), + busy: false, + }; + } + + private checkCanSubmit() { + this.setState({ + canSubmit: + !this.state.busy && + this.state.question.trim().length > 0 && + this.state.options.filter(op => op.trim().length > 0).length >= MIN_OPTIONS, + }); + } + + private onQuestionChange = (e: ChangeEvent) => { + this.setState({ question: e.target.value }, () => this.checkCanSubmit()); + }; + + private onOptionChange = (i: number, e: ChangeEvent) => { + const newOptions = arrayFastClone(this.state.options); + newOptions[i] = e.target.value; + this.setState({ options: newOptions }, () => this.checkCanSubmit()); + }; + + private onOptionRemove = (i: number) => { + const newOptions = arrayFastClone(this.state.options); + newOptions.splice(i, 1); + this.setState({ options: newOptions }, () => this.checkCanSubmit()); + }; + + private onOptionAdd = () => { + const newOptions = arrayFastClone(this.state.options); + newOptions.push(""); + this.setState({ options: newOptions }, () => { + // Scroll the button into view after the state update to ensure we don't experience + // a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render. + this.addOptionRef.current?.scrollIntoView(); + }); + }; + + protected submit(): void { + this.setState({ busy: true, canSubmit: false }); + this.matrixClient.sendEvent( + this.props.room.roomId, + POLL_START_EVENT_TYPE.name, + makePollContent(this.state.question, this.state.options, POLL_KIND_DISCLOSED.name), + ).then(() => this.props.onFinished(true)).catch(e => { + console.error("Failed to submit poll event:", e); + this.setState({ busy: false, canSubmit: true }); + }); + } + + protected cancel(): void { + this.props.onFinished(false); + } + + protected renderContent(): React.ReactNode { + return
+

{ _t("What is your poll question or topic?") }

+ +

{ _t("Create options") }

+ { + this.state.options.map((op, i) =>
+ this.onOptionChange(i, e)} + usePlaceholderAsHint={true} + /> + this.onOptionRemove(i)} + className="mx_PollCreateDialog_removeOption" + /> +
) + } + = MAX_OPTIONS} + kind="secondary" + className="mx_PollCreateDialog_addOption" + inputRef={this.addOptionRef} + >{ _t("Add option") } +
; + } +} diff --git a/src/components/views/elements/Slider.tsx b/src/components/views/elements/Slider.tsx index df5776648e..a201659e3c 100644 --- a/src/components/views/elements/Slider.tsx +++ b/src/components/views/elements/Slider.tsx @@ -86,7 +86,9 @@ export default class Slider extends React.Component { if (!this.props.disabled) { const offset = this.offset(this.props.values, this.props.value); selection =
-
+
+
{ this.props.value }
+

; } diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index b609f7159e..05272f515d 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -17,8 +17,15 @@ limitations under the License. import React from "react"; import { randomString } from "matrix-js-sdk/src/randomstring"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import classnames from 'classnames'; + +export enum CheckboxStyle { + Solid = "solid", + Outline = "outline", +} interface IProps extends React.InputHTMLAttributes { + kind?: CheckboxStyle; } interface IState { @@ -40,13 +47,21 @@ export default class StyledCheckbox extends React.PureComponent public render() { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const { children, className, ...otherProps } = this.props; - return + const { children, className, kind = CheckboxStyle.Solid, ...otherProps } = this.props; + const newClassName = classnames( + "mx_Checkbox", + className, + { + "mx_Checkbox_hasKind": kind, + [`mx_Checkbox_kind_${kind}`]: kind, + }, + ); + return